2024年3月

前言

我们每天写的
vue
代码都是写在
vue
文件中,但是浏览器却只认识
html

css

js
等文件类型。所以这个时候就需要一个工具将
vue
文件转换为浏览器能够认识的
js
文件,想必你第一时间就想到了
webpack
或者
vite
。但是
webpack

vite
本身是没有能力处理
vue
文件的,其实实际背后生效的是
vue-loader

@vitejs/plugin-vue
。本文以
@vitejs/plugin-vue
举例,通过
debug
的方式带你一步一步的搞清楚
vue
文件是如何编译为
js
文件的,
看不懂你来打我

举个例子

这个是我的源代码
App.vue
文件:

<template>
  <h1 class="msg">{{ msg }}</h1>
</template>

<script setup lang="ts">
import { ref } from "vue";

const msg = ref("hello word");
</script>

<style scoped>
.msg {
  color: red;
  font-weight: bold;
}
</style>

这个例子很简单,在
setup
中定义了
msg
变量,然后在
template
中将
msg
渲染出来。

下面这个是我从
network
中找到的编译后的
js
文件,已经精简过了:

import {
  createElementBlock as _createElementBlock,
  defineComponent as _defineComponent,
  openBlock as _openBlock,
  toDisplayString as _toDisplayString,
  ref,
} from "/node_modules/.vite/deps/vue.js?v=23bfe016";
import "/src/App.vue?vue&type=style&index=0&scoped=7a7a37b1&lang.css";

const _sfc_main = _defineComponent({
  __name: "App",
  setup(__props, { expose: __expose }) {
    __expose();
    const msg = ref("hello word");
    const __returned__ = { msg };
    return __returned__;
  },
});

const _hoisted_1 = { class: "msg" };
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
  return (
    _openBlock(),
    _createElementBlock(
      "h1",
      _hoisted_1,
      _toDisplayString($setup.msg),
      1
      /* TEXT */
    )
  );
}

__sfc__.render = render;
export default _sfc_main;

编译后的
js
代码中我们可以看到主要有三部分,想必你也猜到了这三部分刚好对应
vue
文件的那三块。

  • _sfc_main
    对象的
    setup
    方法对应
    vue
    文件中的
    <script setup lang="ts">
    模块。
  • _sfc_render
    函数对应
    vue
    文件中的
    <template>
    模块。
  • import "/src/App.vue?vue&type=style&index=0&scoped=7a7a37b1&lang.css";
    对应
    vue
    文件中的
    <style scoped>
    模块。

debug搞清楚如何将
vue
文件编译为
js
文件

大家应该都知道,前端代码运行环境主要有两个,
node
端和浏览器端,分别对应我们熟悉的编译时和运行时。浏览器明显是不认识
vue
文件的,所以
vue
文件编译成
js
这一过程肯定不是在运行时的浏览器端。很明显这一过程是在编译时的
node
端。

要在
node
端打断点,我们需要启动一个debug 终端。这里以
vscode
举例,首先我们需要打开终端,然后点击终端中的
+
号旁边的下拉箭头,在下拉中点击
Javascript Debug Terminal
就可以启动一个
debug
终端。
debug-terminal

假如
vue
文件编译为
js
文件是一个毛线团,那么他的线头一定是
vite.config.ts
文件中使用
@vitejs/plugin-vue
的地方。通过这个线头开始
debug
我们就能够梳理清楚完整的工作流程。
vite-config

vuePlugin函数

我们给上方图片的
vue
函数打了一个断点,然后在
debug
终端上面执行
yarn dev
,我们看到断点已经停留在了
vue
函数这里。然后点击
step into
,断点走到了
@vitejs/plugin-vue
库中的一个
vuePlugin
函数中。我们看到
vuePlugin
函数中的内容代码大概是这样的:

function vuePlugin(rawOptions = {}) {
const options = shallowRef({
    compiler: null,
    // 省略...
  });

  return {
    name: "vite:vue",
    handleHotUpdate(ctx) {
      // ...
    },
    config(config) {
      // ..
    },
    configResolved(config) {
      // ..
    },
    configureServer(server) {
      // ..
    },
    buildStart() {
      // ..
    },
    async resolveId(id) {
      // ..
    },
    load(id, opt) {
      // ..
    },
    transform(code, id, opt) {
      // ..
    }
  };
}

@vitejs/plugin-vue
是作为一个
plugins
插件在
vite
中使用,
vuePlugin
函数返回的对象中的
buildStart

transform
方法就是对应的插件钩子函数。
vite
会在对应的时候调用这些插件的钩子函数,比如当
vite
服务器启动时就会调用插件里面的
buildStart
等函数,当
vite
解析每个模块时就会调用
transform
等函数。更多
vite
钩子相关内容
查看官网

我们这里主要看
buildStart

transform
两个钩子函数,分别是服务器启动时调用和解析每个模块时调用。给这两个钩子函数打上断点。
vue

然后点击Continue(F5),
vite
服务启动后就会走到
buildStart
钩子函数中打的断点。我们可以看到
buildStart
钩子函数的代码是这样的:

buildStart() {
  const compiler = options.value.compiler = options.value.compiler || resolveCompiler(options.value.root);
}

将鼠标放到
options.value.compiler
上面我们看到此时
options.value.compiler
的值为
null
,所以代码会走到
resolveCompiler
函数中,点击Step Into(F11)走到
resolveCompiler
函数中。看到
resolveCompiler
函数代码如下:

function resolveCompiler(root) {
  const compiler = tryResolveCompiler(root) || tryResolveCompiler();
  return compiler;
}

function tryResolveCompiler(root) {
  const vueMeta = tryRequire("vue/package.json", root);
  if (vueMeta && vueMeta.version.split(".")[0] >= 3) {
    return tryRequire("vue/compiler-sfc", root);
  }
}


resolveCompiler
函数中调用了
tryResolveCompiler
函数,在
tryResolveCompiler
函数中判断当前项目是否是
vue3.x
版本,然后将
vue/compiler-sfc
包返回。
所以经过初始化后
options.value.compiler
的值就是
vue
的底层库
vue/compiler-sfc
,记住这个后面会用

然后点击Continue(F5)放掉断点,在浏览器中打开对应的页面,比如:
http://localhost:5173/
。此时
vite
将会编译这个页面要用到的所有文件,就会走到
transform
钩子函数断点中了。由于解析每个文件都会走到
transform
钩子函数中,但是我们只关注
App.vue
文件是如何解析的,所以为了方便我们直接在
transform
函数中添加了下面这段代码,并且删掉了原来在
transform
钩子函数中打的断点,这样就只有解析到
App.vue
文件的时候才会走到断点中去。
transform

经过debug我们发现解析
App.vue
文件时
transform
函数实际就是执行了
transformMain
函数,至于
transformStyle
函数后面讲解析
style
的时候会讲:

transform(code, id, opt) {
  const { filename, query } = parseVueRequest(id);
  if (!query.vue) {
    return transformMain(
      code,
      filename,
      options.value,
      this,
      ssr,
      customElementFilter.value(filename)
    );
  } else {
    const descriptor = query.src ? getSrcDescriptor(filename, query) || getTempSrcDescriptor(filename, query) : getDescriptor(filename, options.value);
    if (query.type === "style") {
      return transformStyle(
        code,
        descriptor,
        Number(query.index || 0),
        options.value,
        this,
        filename
      );
    }
  }
}

transformMain
函数

继续debug断点走进
transformMain
函数,发现
transformMain
函数中代码逻辑很清晰。按照顺序分别是:

  • 根据源代码code字符串调用
    createDescriptor
    函数生成一个
    descriptor
    对象。
  • 调用
    genScriptCode
    函数传入第一步生成的
    descriptor
    对象将
    <script setup>
    模块编译为浏览器可执行的
    js
    代码。
  • 调用
    genTemplateCode
    函数传入第一步生成的
    descriptor
    对象将
    <template>
    模块编译为
    render
    函数。
  • 调用
    genStyleCode
    函数传入第一步生成的
    descriptor
    对象将
    <style scoped>
    模块编译为类似这样的
    import
    语句,
    import "/src/App.vue?vue&type=style&index=0&scoped=7a7a37b1&lang.css";

createDescriptor
函数

我们先来看看
createDescriptor
函数,将断点走到
createDescriptor(filename, code, options)
这一行代码,可以看到传入的
filename
就是
App.vue
的文件路径,
code
就是
App.vue
中我们写的源代码。
createDescriptor

debug
走进
createDescriptor
函数,看到
createDescriptor
函数的代码如下:

function createDescriptor(filename, source, { root, isProduction, sourceMap, compiler, template }, hmr = false) {
  const { descriptor, errors } = compiler.parse(source, {
    filename,
    sourceMap,
    templateParseOptions: template?.compilerOptions
  });
  const normalizedPath = slash(path.normalize(path.relative(root, filename)));
  descriptor.id = getHash(normalizedPath + (isProduction ? source : ""));
  return { descriptor, errors };
}

这个
compiler
是不是觉得有点熟悉?
compiler
是调用
createDescriptor
函数时传入的第三个参数解构而来,而第三个参数就是
options
。还记得我们之前在
vite
启动时调用了
buildStart
钩子函数,然后将
vue
底层包
vue/compiler-sfc
赋值给
options

compiler
属性。那这里的
compiler.parse
其实就是调用的
vue/compiler-sfc
包暴露出来的
parse
函数,这是一个
vue
暴露出来的底层的
API
,这篇文章我们不会对底层API进行源码解析,通过查看
parse
函数的输入和输出基本就可以搞清楚
parse
函数的作用。下面这个是
parse
函数的类型定义:

export function parse(
source: string,
options: SFCParseOptions = {},
): SFCParseResult {}

从上面我们可以看到
parse
函数接收两个参数,第一个参数为
vue
文件的源代码,在我们这里就是
App.vue
中的
code
字符串,第二个参数是一些
options
选项。
我们再来看看
parse
函数的返回值
SFCParseResult
,主要有类型为
SFCDescriptor

descriptor
属性需要关注。

export interface SFCParseResult {
  descriptor: SFCDescriptor
  errors: (CompilerError | SyntaxError)[]
}

export interface SFCDescriptor {
  filename: string
  source: string
  template: SFCTemplateBlock | null
  script: SFCScriptBlock | null
  scriptSetup: SFCScriptBlock | null
  styles: SFCStyleBlock[]
  customBlocks: SFCBlock[]
  cssVars: string[]
  slotted: boolean
  shouldForceReload: (prevImports: Record<string, ImportBinding>) => boolean
}

仔细看看
SFCDescriptor
类型,其中的
template
属性就是
App.vue
文件对应的
template
标签中的内容,里面包含了由
App.vue
文件中的
template
模块编译成的
AST抽象语法树
和原始的
template
中的代码。
template

我们再来看
script

scriptSetup
属性,由于
vue
文件中可以写多个
script
标签,
scriptSetup
对应的就是有
setup

script
标签,
script
对应的就是没有
setup
对应的
script
标签。我们这个场景中只有
scriptSetup
属性,里面同样包含了
App.vue
中的
script
模块中的内容。
script

我们再来看看
styles
属性,这里的
styles
属性是一个数组,是因为我们可以在
vue
文件中写多个
style
模块,里面同样包含了
App.vue
中的
style
模块中的内容。
style

所以这一步执行
createDescriptor
函数生成的
descriptor
对象中主要有三个属性,
template
属性包含了
App.vue
文件中的
template
模块
code
字符串和
AST抽象语法树

scriptSetup
属性包含了
App.vue
文件中的
<script setup>
模块的
code
字符串,
styles
属性包含了
App.vue
文件中
<style>
模块中的
code
字符串。
createDescriptor
函数的执行流程图如下:
progress-createDescriptor

genScriptCode
函数

我们再来看
genScriptCode
函数是如何将
<script setup>
模块编译成可执行的
js
代码,同样将断点走到调用
genScriptCode
函数的地方,
genScriptCode
函数主要接收我们上一步生成的
descriptor
对象,调用
genScriptCode
函数后会将编译后的
script
模块代码赋值给
scriptCode
变量。

const { code: scriptCode, map: scriptMap } = await genScriptCode(
  descriptor,
  options,
  pluginContext,
  ssr,
  customElement
);

将断点走到
genScriptCode
函数内部,在
genScriptCode
函数中主要就是这行代码:
const script = resolveScript(descriptor, options, ssr, customElement);
。将第一步生成的
descriptor
对象作为参数传给
resolveScript
函数,返回值就是编译后的
js
代码,
genScriptCode
函数的代码简化后如下:

async function genScriptCode(descriptor, options, pluginContext, ssr, customElement) {
  let scriptCode = `const ${scriptIdentifier} = {}`;
  const script = resolveScript(descriptor, options, ssr, customElement);
  if (script) {
    scriptCode = script.content;
    map = script.map;
  }
  return {
    code: scriptCode,
    map
  };
}

我们继续将断点走到
resolveScript
函数内部,发现
resolveScript
中的代码其实也很简单,简化后的代码如下:

function resolveScript(descriptor, options, ssr, customElement) {
  let resolved = null;
  resolved = options.compiler.compileScript(descriptor, {
    ...options.script,
    id: descriptor.id,
    isProd: options.isProduction,
    inlineTemplate: isUseInlineTemplate(descriptor, !options.devServer),
    templateOptions: resolveTemplateCompilerOptions(descriptor, options, ssr),
    sourceMap: options.sourceMap,
    genDefaultAs: canInlineMain(descriptor, options) ? scriptIdentifier : void 0,
    customElement
  });
  return resolved;
}

这里的
options.compiler
我们前面第一步的时候已经解释过了,
options.compiler
对象实际就是
vue
底层包
vue/compiler-sfc
暴露的对象,这里的
options.compiler.compileScript()
其实就是调用的
vue/compiler-sfc
包暴露出来的
compileScript
函数,同样也是一个
vue
暴露出来的底层的
API
,后面我们的分析
defineOptions
等文章时会去深入分析
compileScript
函数,这篇文章我们不会去读
compileScript
函数的源码。通过查看
compileScript
函数的输入和输出基本就可以搞清楚
compileScript
函数的作用。下面这个是
compileScript
函数的类型定义:

export function compileScript(
  sfc: SFCDescriptor,
  options: SFCScriptCompileOptions,
): SFCScriptBlock{}

这个函数的入参是一个
SFCDescriptor
对象,就是我们第一步调用生成
createDescriptor
函数生成的
descriptor
对象,第二个参数是一些
options
选项。我们再来看返回值
SFCScriptBlock
类型:

export interface SFCScriptBlock extends SFCBlock {
  type: 'script'
  setup?: string | boolean
  bindings?: BindingMetadata
  imports?: Record<string, ImportBinding>
  scriptAst?: import('@babel/types').Statement[]
  scriptSetupAst?: import('@babel/types').Statement[]
  warnings?: string[]
  /**
   * Fully resolved dependency file paths (unix slashes) with imported types
   * used in macros, used for HMR cache busting in @vitejs/plugin-vue and
   * vue-loader.
   */
  deps?: string[]
}

export interface SFCBlock {
  type: string
  content: string
  attrs: Record<string, string | true>
  loc: SourceLocation
  map?: RawSourceMap
  lang?: string
  src?: string
}

返回值类型中主要有
scriptAst

scriptSetupAst

content
这三个属性,
scriptAst
为编译不带
setup
属性的
script
标签生成的AST抽象语法树。
scriptSetupAst
为编译带
setup
属性的
script
标签生成的AST抽象语法树,
content

vue
文件中的
script
模块编译后生成的浏览器可执行的
js
代码。下面这个是执行
vue/compiler-sfc

compileScript
函数返回结果:
resolved

继续将断点走回
genScriptCode
函数,现在逻辑就很清晰了。这里的
script
对象就是调用
vue/compiler-sfc

compileScript
函数返回对象,
scriptCode
就是
script
对象的
content
属性 ,也就是将
vue
文件中的
script
模块经过编译后生成浏览器可直接执行的
js
代码
code
字符串。

async function genScriptCode(descriptor, options, pluginContext, ssr, customElement) {
  let scriptCode = `const ${scriptIdentifier} = {}`;
  const script = resolveScript(descriptor, options, ssr, customElement);
  if (script) {
    scriptCode = script.content;
    map = script.map;
  }
  return {
    code: scriptCode,
    map
  };
}

genScriptCode
函数的执行流程图如下:
progress-genScriptCode

genTemplateCode
函数

我们再来看
genTemplateCode
函数是如何将
template
模块编译成
render
函数的,同样将断点走到调用
genTemplateCode
函数的地方,
genTemplateCode
函数主要接收我们上一步生成的
descriptor
对象,调用
genTemplateCode
函数后会将编译后的
template
模块代码赋值给
templateCode
变量。

({ code: templateCode, map: templateMap } = await genTemplateCode(
  descriptor,
  options,
  pluginContext,
  ssr,
  customElement
))

同样将断点走到
genTemplateCode
函数内部,在
genTemplateCode
函数中主要就是返回
transformTemplateInMain
函数的返回值,
genTemplateCode
函数的代码简化后如下:

async function genTemplateCode(descriptor, options, pluginContext, ssr, customElement) {
  const template = descriptor.template;
  return transformTemplateInMain(
    template.content,
    descriptor,
    options,
    pluginContext,
    ssr,
    customElement
  );
}

我们继续将断点走进
transformTemplateInMain
函数,发现这里也主要是调用
compile
函数,代码如下:

function transformTemplateInMain(code, descriptor, options, pluginContext, ssr, customElement) {
  const result = compile(
    code,
    descriptor,
    options,
    pluginContext,
    ssr,
    customElement
  );
  return {
    ...result,
    code: result.code.replace(
      /\nexport (function|const) (render|ssrRender)/,
      "\n$1 _sfc_$2"
    )
  };
}

同理将断点走进到
compile
函数内部,我们看到
compile
函数的代码是下面这样的:

function compile(code, descriptor, options, pluginContext, ssr, customElement) {
  const result = options.compiler.compileTemplate({
    ...resolveTemplateCompilerOptions(descriptor, options, ssr),
    source: code
  });
  return result;
}

同样这里也用到了
options.compiler
,调用
options.compiler.compileTemplate()
其实就是调用的
vue/compiler-sfc
包暴露出来的
compileTemplate
函数,这也是一个
vue
暴露出来的底层的
API
。不过这里和前面不同的是
compileTemplate
接收的不是
descriptor
对象,而是一个
SFCTemplateCompileOptions
类型的对象,所以这里需要调用
resolveTemplateCompilerOptions
函数将参数转换成
SFCTemplateCompileOptions
类型的对象。这篇文章我们不会对底层API进行解析。通过查看
compileTemplate
函数的输入和输出基本就可以搞清楚
compileTemplate
函数的作用。下面这个是
compileTemplate
函数的类型定义:

export function compileTemplate(
  options: SFCTemplateCompileOptions,
): SFCTemplateCompileResults {}

入参
options
主要就是需要编译的
template
中的源代码和对应的
AST抽象语法树
。我们来看看返回值
SFCTemplateCompileResults
,这里面的
code
就是编译后的
render
函数字符串。

export interface SFCTemplateCompileResults {
  code: string
  ast?: RootNode
  preamble?: string
  source: string
  tips: string[]
  errors: (string | CompilerError)[]
  map?: RawSourceMap
}

render

genTemplateCode
函数的执行流程图如下:
progress-genTemplateCode

genStyleCode
函数

我们再来看最后一个
genStyleCode
函数,同样将断点走到调用
genStyleCode
的地方。一样的接收
descriptor
对象。代码如下:

const stylesCode = await genStyleCode(
  descriptor,
  pluginContext,
  customElement,
  attachedProps
);

我们将断点走进
genStyleCode
函数内部,发现和前面
genScriptCode

genTemplateCode
函数有点不一样,下面这个是我简化后的
genStyleCode
函数代码:

async function genStyleCode(descriptor, pluginContext, customElement, attachedProps) {
  let stylesCode = ``;
  if (descriptor.styles.length) {
    for (let i = 0; i < descriptor.styles.length; i++) {
      const style = descriptor.styles[i];
      const src = style.src || descriptor.filename;
      const attrsQuery = attrsToQuery(style.attrs, "css");
      const srcQuery = style.src ? style.scoped ? `&src=${descriptor.id}` : "&src=true" : "";
      const directQuery = customElement ? `&inline` : ``;
      const scopedQuery = style.scoped ? `&scoped=${descriptor.id}` : ``;
      const query = `?vue&type=style&index=${i}${srcQuery}${directQuery}${scopedQuery}`;
      const styleRequest = src + query + attrsQuery;
      stylesCode += `
import ${JSON.stringify(styleRequest)}`;
    }
  }
  return stylesCode;
}

我们前面讲过因为
vue
文件中可能会有多个
style
标签,所以
descriptor
对象的
styles
属性是一个数组。遍历
descriptor.styles
数组,我们发现
for
循环内全部都是一堆赋值操作,没有调用
vue/compiler-sfc
包暴露出来的任何
API
。将断点走到
return stylesCode;
,看看
stylesCode
到底是什么东西?
styleCode

通过打印我们发现
stylesCode
竟然变成了一条
import
语句,并且
import
的还是当前
App.vue
文件,只是多了几个
query
分别是:
vue

type

index

scoped

lang
。再来回忆一下前面讲的
@vitejs/plugin-vue

transform
钩子函数,当
vite
解析每个模块时就会调用
transform
等函数。所以当代码运行到这行
import
语句的时候会再次走到
transform
钩子函数中。我们再来看看
transform
钩子函数的代码:

transform(code, id, opt) {
  const { filename, query } = parseVueRequest(id);
  if (!query.vue) {
    // 省略
  } else {
    const descriptor = query.src ? getSrcDescriptor(filename, query) || getTempSrcDescriptor(filename, query) : getDescriptor(filename, options.value);
    if (query.type === "style") {
      return transformStyle(
        code,
        descriptor,
        Number(query.index || 0),
        options.value,
        this,
        filename
      );
    }
  }
}


query
中有
vue
字段,并且
query

type
字段值为
style
时就会执行
transformStyle
函数,我们给
transformStyle
函数打个断点。当执行上面那条
import
语句时就会走到断点中,我们进到
transformStyle
中看看。

async function transformStyle(code, descriptor, index, options, pluginContext, filename) {
  const block = descriptor.styles[index];
  const result = await options.compiler.compileStyleAsync({
    ...options.style,
    filename: descriptor.filename,
    id: `data-v-${descriptor.id}`,
    isProd: options.isProduction,
    source: code,
    scoped: block.scoped,
    ...options.cssDevSourcemap ? {
      postcssOptions: {
        map: {
          from: filename,
          inline: false,
          annotation: false
        }
      }
    } : {}
  });
  return {
    code: result.code,
    map
  };
}

transformStyle
函数的实现我们看着就很熟悉了,和前面处理
template

script
一样都是调用的
vue/compiler-sfc
包暴露出来的
compileStyleAsync
函数,这也是一个
vue
暴露出来的底层的
API
。同样我们不会对底层API进行解析。通过查看
compileStyleAsync
函数的输入和输出基本就可以搞清楚
compileStyleAsync
函数的作用。

export function compileStyleAsync(
  options: SFCAsyncStyleCompileOptions,
): Promise<SFCStyleCompileResults> {}

我们先来看看
SFCAsyncStyleCompileOptions
入参:

interface SFCAsyncStyleCompileOptions extends SFCStyleCompileOptions {
  isAsync?: boolean
  modules?: boolean
  modulesOptions?: CSSModulesOptions
}

interface SFCStyleCompileOptions {
  source: string
  filename: string
  id: string
  scoped?: boolean
  trim?: boolean
  isProd?: boolean
  inMap?: RawSourceMap
  preprocessLang?: PreprocessLang
  preprocessOptions?: any
  preprocessCustomRequire?: (id: string) => any
  postcssOptions?: any
  postcssPlugins?: any[]
  map?: RawSourceMap
}

入参主要关注几个字段,
source
字段为
style
标签中的
css
原始代码。
scoped
字段为
style
标签中是否有
scoped
attribute。
id
字段为我们在观察 DOM 结构时看到的
data-v-xxxxx
。这个是
debug
时入参截图:
transformStyle-arg

再来看看返回值
SFCStyleCompileResults
对象,主要就是
code
属性,这个是经过编译后的
css
字符串,已经加上了
data-v-xxxxx

interface SFCStyleCompileResults {
  code: string
  map: RawSourceMap | undefined
  rawResult: Result | LazyResult | undefined
  errors: Error[]
  modules?: Record<string, string>
  dependencies: Set<string>
}

这个是
debug

compileStyleAsync
函数返回值的截图:
transformStyle-res

genStyleCode
函数的执行流程图如下:
progress-genStyleCode

transformMain
函数简化后的代码

现在我们可以来看
transformMain
函数简化后的代码:

async function transformMain(code, filename, options, pluginContext, ssr, customElement) {
  const { descriptor, errors } = createDescriptor(filename, code, options);

  const { code: scriptCode, map: scriptMap } = await genScriptCode(
    descriptor,
    options,
    pluginContext,
    ssr,
    customElement
  );

  let templateCode = "";
  ({ code: templateCode, map: templateMap } = await genTemplateCode(
    descriptor,
    options,
    pluginContext,
    ssr,
    customElement
  ));

  const stylesCode = await genStyleCode(
    descriptor,
    pluginContext,
    customElement,
    attachedProps
  );

  const output = [
    scriptCode,
    templateCode,
    stylesCode
  ];
  let resolvedCode = output.join("\n");
  return {
    code: resolvedCode,
    map: resolvedMap || {
      mappings: ""
    },
    meta: {
      vite: {
        lang: descriptor.script?.lang || descriptor.scriptSetup?.lang || "js"
      }
    }
  };
}

transformMain
函数中的代码执行主流程,其实就是对应了一个
vue
文件编译成
js
文件的流程。

首先调用
createDescriptor
函数将一个
vue
文件解析为一个
descriptor
对象。

然后以
descriptor
对象为参数调用
genScriptCode
函数,将
vue
文件中的
<script>
模块代码编译成浏览器可执行的
js
代码
code
字符串,赋值给
scriptCode
变量。

接着以
descriptor
对象为参数调用
genTemplateCode
函数,将
vue
文件中的
<template>
模块代码编译成
render
函数
code
字符串,赋值给
templateCode
变量。

然后以
descriptor
对象为参数调用
genStyleCode
函数,将
vue
文件中的
<style>
模块代码编译成了
import
语句
code
字符串,比如:
import "/src/App.vue?vue&type=style&index=0&scoped=7a7a37b1&lang.css";
,赋值给
stylesCode
变量。

然后将
scriptCode

templateCode

stylesCode
使用换行符
\n
拼接起来得到
resolvedCode
,这个
resolvedCode
就是一个
vue
文件编译成
js
文件的代码
code
字符串。这个是
debug

resolvedCode
变量值的截图:
resolvedCode

总结

这篇文章通过
debug
的方式一步一步的带你了解
vue
文件编译成
js
文件的完整流程,下面是一个完整的流程图。如果文字太小看不清,可以将图片保存下来或者放大看:
progress-full

@vitejs/plugin-vue-jsx
库中有个叫
transform
的钩子函数,每当
vite
加载模块的时候就会触发这个钩子函数。所以当
import
一个
vue
文件的时候,就会走到
@vitejs/plugin-vue-jsx
中的
transform
钩子函数中,在
transform
钩子函数中主要调用了
transformMain
函数。

第一次解析这个
vue
文件时,在
transform
钩子函数中主要调用了
transformMain
函数。在
transformMain
函数中主要调用了4个函数,分别是:
createDescriptor

genScriptCode

genTemplateCode

genStyleCode

createDescriptor
接收的参数为当前
vue
文件代码
code
字符串,返回值为一个
descriptor
对象。对象中主要有四个属性
template

scriptSetup

script

styles

  • descriptor.template.ast
    就是由
    vue
    文件中的
    template
    模块生成的
    AST抽象语法树
  • descriptor.template.content
    就是
    vue
    文件中的
    template
    模块的代码字符串。
  • scriptSetup

    script
    的区别是分别对应的是
    vue
    文件中有
    setup
    属性的
    <script>
    模块和无
    setup
    属性的
    <script>
    模块。
    descriptor.scriptSetup.content
    就是
    vue
    文件中的
    <script setup>
    模块的代码字符串。

genScriptCode
函数为底层调用
vue/compiler-sfc

compileScript
函数,根据第一步的
descriptor
对象将
vue
文件的
<script setup>
模块转换为浏览器可直接执行的
js
代码。

genTemplateCode
函数为底层调用
vue/compiler-sfc

compileTemplate
函数,根据第一步的
descriptor
对象将
vue
文件的
<template>
模块转换为
render
函数。

genStyleCode
函数为将
vue
文件的
style
模块转换为
import "/src/App.vue?vue&type=style&index=0&scoped=7a7a37b1&lang.css";
样子的
import
语句。

然后使用换行符
\n

genScriptCode
函数、
genTemplateCode
函数、
genStyleCode
函数的返回值拼接起来赋值给变量
resolvedCode
,这个
resolvedCode
就是
vue
文件编译成
js
文件的
code
字符串。

当浏览器执行到
import "/src/App.vue?vue&type=style&index=0&scoped=7a7a37b1&lang.css";
语句时,触发了加载模块操作,再次触发了
@vitejs/plugin-vue-jsx
中的
transform
钩子函数。此时由于有了
type=style

query
,所以在
transform
函数中会执行
transformStyle
函数,在
transformStyle
函数中同样也是调用
vue/compiler-sfc

compileStyleAsync
函数,根据第一步的
descriptor
对象将
vue
文件的
<style>
模块转换为编译后的
css
代码
code
字符串,至此编译
style
部分也讲完了。

关注公众号:
前端欧阳
,解锁我更多
vue
干货文章,并且可以免费向我咨询
vue
相关问题。
qrcode

相关win32api的学习✨

SetParent

[DllImport("user32.dll ", EntryPoint = "SetParent")]
private static extern IntPtr SetParent(IntPtr hWndChild, IntPtr hWndNewParent);   //将外部窗体嵌入程序

image-20240305202650242

语法:

HWND SetParent(
  [in]           HWND hWndChild,
  [in, optional] HWND hWndNewParent
);

参数:

参数名 类型 含义
hWndChild HWND 子窗口的句柄
hWndNewParent HWND 新父窗口的句柄。 如果此参数为
NULL
,桌面窗口将成为新的父窗口。 如果此参数
HWND_MESSAGE
,则子窗口将成为
仅消息窗口

相关解释:

什么是句柄

在计算机编程和操作系统中,句柄(Handle)是一个用于标识和引用对象或资源的抽象概念。它通常是一个整数值或指针,充当对特定资源的引用或访问标识符。句柄用于管理内存、设备、文件、窗口等各种资源。

句柄在操作系统中被广泛使用,特别是在图形用户界面(GUI)应用程序中。例如,在Windows操作系统中,窗口句柄(Window Handle)用于标识和操作窗口对象。每个窗口都有一个唯一的句柄,通过该句柄可以执行诸如移动、调整大小、关闭等操作。

另一个常见的例子是文件句柄(File Handle),它用于标识和操作打开的文件。通过文件句柄,程序可以读取、写入或关闭文件。

句柄的使用可以提高程序的安全性和效率。它们允许程序通过句柄而不是直接访问资源,从而隐藏底层实现细节并提供一种统一的接口。此外,句柄还可以用于实现资源的共享和保护,通过对句柄的权限管理来控制对资源的访问。

总的来说,句柄是一种重要的编程概念,用于标识和管理各种资源,从而使程序能够有效地操作系统资源,并提供安全和统一的访问接口。

FindWindow

 [DllImport("user32.dll")]
 public static extern IntPtr FindWindow(string lpszClass, string lpszWindow);      //按照窗体类名或窗体标题查找窗体

作用:检索其类名和窗口名称与指定字符串匹配的顶级窗口的句柄。 此函数不搜索子窗口。 此函数不执行区分大小写的搜索。

若要从指定的子窗口开始搜索子窗口,请使用
FindWindowEx
函数。

语法:

HWND FindWindowA(
  [in, optional] LPCSTR lpClassName,
  [in, optional] LPCSTR lpWindowName
);

参数:

参数名 类型 含义
lpClassName LPCTSTR 如果
lpClassName
指向字符串,则指定窗口类名。
lpWindowName LPCTSTR 窗口名称 (窗口标题) 。 如果此参数为
NULL
,则所有窗口名称都匹配。

ShowWindow

image-20240306122438035

作用:设置指定窗口的显示状态。

语法:

HWND FindWindowA(
  [in, optional] LPCSTR lpClassName,
  [in, optional] LPCSTR lpWindowName
);

参数:

参数名 类型 含义
hWnd HWND 窗口的句柄。
nCmdShow int 控制窗口的显示方式。

nCmdShow不同值与含义:

0 隐藏窗口并激活另一个窗口。
1 激活并显示窗口。 如果窗口最小化、最大化或排列,系统会将其还原到其原始大小和位置。 应用程序应在首次显示窗口时指定此标志。
2 激活窗口并将其显示为最小化窗口。
3 激活窗口并显示最大化的窗口。
4 以最近的大小和位置显示窗口。
5 激活窗口并以当前大小和位置显示窗口。
6 最小化指定的窗口,并按 Z 顺序激活下一个顶级窗口。
7 将窗口显示为最小化窗口。
8 以当前大小和位置显示窗口。
9 激活并显示窗口。 如果窗口最小化、最大化或排列,系统会将其还原到其原始大小和位置。 还原最小化窗口时,应用程序应指定此标志。
10 根据启动应用程序的程序传递给
CreateProcess
函数的
STARTUPINFO
结构中指定的
SW_
值设置显示状态。
11 最小化窗口,即使拥有窗口的线程没有响应。 仅当最小化不同线程的窗口时,才应使用此标志。

创建一个静态类✨

为了便于进行相关的操作,创建一个静态类:

  public static class WindowManager
  {
      public static IntPtr intPtr;         //第三方应用窗口的句柄

      /// <summary>
      /// 调整第三方应用窗体大小
      /// </summary>
      public static void ResizeWindow()
      {
          ShowWindow(intPtr, 0);  //先将窗口隐藏
          ShowWindow(intPtr, 3);  //再将窗口最大化,可以让第三方窗口自适应容器的大小
      }

      /// <summary>
      /// 循环查找第三方窗体
      /// </summary>
      /// <returns></returns>
      public static bool FindWindow(string formName)
      {
          for (int i = 0; i < 100; i++)
          {
              //按照窗口标题查找Python窗口
              IntPtr vHandle = FindWindow(null, formName);
              if (vHandle == IntPtr.Zero)
              {
                  Thread.Sleep(100);  //每100ms查找一次,直到找到,最多查找10s
                  continue;
              }
              else      //找到返回True
              {
                  intPtr = vHandle;
                  return true;
              }
          }
          intPtr = IntPtr.Zero;
          return false;
      }


      /// <summary>
      /// 将第三方窗体嵌入到容器内
      /// </summary>
      /// <param name="hWndNewParent">父容器句柄</param>
      /// <param name="windowName">窗体名</param>
      public static void SetParent(IntPtr hWndNewParent, string windowName)
      {
          ShowWindow(intPtr, 0);                 //先将窗体隐藏,防止出现闪烁
          SetParent(intPtr, hWndNewParent);      //将第三方窗体嵌入父容器                    
          Thread.Sleep(100);                      //略加延时
          ShowWindow(intPtr, 3);                 //让第三方窗体在容器中最大化显示
          RemoveWindowTitle(intPtr);             // 去除窗体标题
      }


      /// <summary>
      /// 去除窗体标题
      /// </summary>
      /// <param name="vHandle">窗口句柄</param>
      public static void RemoveWindowTitle(IntPtr vHandle)
      {
          long style = GetWindowLong(vHandle, -16);
          style &= ~0x00C00000;
          SetWindowLong(vHandle, -16, style);
      }


      #region API 需要using System.Runtime.InteropServices;

      [DllImport("user32.dll ", EntryPoint = "SetParent")]
      private static extern IntPtr SetParent(IntPtr hWndChild, IntPtr hWndNewParent);   //将外部窗体嵌入程序

      [DllImport("user32.dll")]
      public static extern IntPtr FindWindow(string lpszClass, string lpszWindow);      //按照窗体类名或窗体标题查找窗体

      [DllImport("user32.dll", EntryPoint = "ShowWindow", CharSet = CharSet.Auto)]
      private static extern int ShowWindow(IntPtr hwnd, int nCmdShow);                  //设置窗体属性

      [DllImport("user32.dll", EntryPoint = "SetWindowLong", CharSet = CharSet.Auto)]
      public static extern IntPtr SetWindowLong(IntPtr hWnd, int nIndex, long dwNewLong);

      [DllImport("user32.dll", EntryPoint = "GetWindowLong", CharSet = CharSet.Auto)]
      public static extern long GetWindowLong(IntPtr hWnd, int nIndex);

      #endregion
  }

首先查看最下方的内容,以

 [DllImport("user32.dll ", EntryPoint = "SetParent")]
 private static extern IntPtr SetParent(IntPtr hWndChild, IntPtr hWndNewParent);   

为例进行说明。

这段代码是在C#中使用平台调用(Platform Invoke,或P/Invoke)来调用Windows的user32.dll中的一个函数,名为SetParent。这是一种在.NET中调用本地方法(通常是C或C++编写的)的技术。

[DllImport("user32.dll ", EntryPoint = "SetParent")]
:这是一个属性,它告诉.NET运行时你要调用的DLL的名称(在这里是"user32.dll")和函数的入口点(在这里是"SetParent")。

private static extern IntPtr SetParent(IntPtr hWndChild, IntPtr hWndNewParent)
:这是函数的声明。它告诉.NET运行时函数的签名。在这个例子中,函数名为SetParent,它接受两个IntPtr类型的参数(hWndChild和hWndNewParent),并返回一个IntPtr类型的值。

在C#中,
extern
关键字用于声明一个方法,该方法在外部实现,通常是在一个DLL中。

在该静态类中定义了一个类型为
IntPtr
的静态成员intPtr表示第三方应用窗口的句柄。

IntPtr类型介绍

在C#中,IntPtr是一个特殊的数据类型,用于表示指针或句柄。它的大小会根据当前操作系统的位数而变化,32位系统下为4字节,64位系统下为8字节。IntPtr主要用于在托管代码和非托管代码之间传递指针或句柄,以及处理不确定性大小的内存操作。它通常用于与操作系统API进行交互、处理内存分配和操作句柄等场景。

IntPtr类型提供了一种安全的方式来处理指针,因为它是托管代码中的数据类型,受到.NET运行时的管理和保护。通过IntPtr,可以在托管代码中安全地表示非托管资源的地址或句柄,而无需担心内存泄漏或其他不安全的操作。

使用IntPtr类型时,需要谨慎处理,并遵循.NET平台的内存管理规则,以确保代码的稳定性和安全性。通常情况下,IntPtr主要用于与非托管代码进行交互,处理平台特定的资源或操作系统API,同时尽量避免直接使用指针操作,以减少内存管理和安全性方面的问题。

这个静态类还有
ResizeWindow

FindWindow

SetParent

RemoveWindowTitle
方法,等后面用到了再做解释。

创建一个winform✨

winform的设计如下所示:

image-20240306150128808

启动软件按钮点击事件处理程序:

 private void button2_Click(object sender, EventArgs e)
 {
     Process.Start("程序路径");
 }

嵌入窗体按钮点击事件处理程序:

  private void button1_Click(object sender, EventArgs e)
  {
      Task.Run(() =>
      {               
          if (WindowManager.FindWindow("Sysplorer [演示版]"))
          {
              this.Invoke(new Action(() =>
              {
                  WindowManager.SetParent(panel1.Handle, "Sysplorer [演示版]");  
              }));
          }
          else
          {
              MessageBox.Show("未能查找到窗体");
          }
      });
  }

在这里就会遇到一个问题就是如何确定窗体的标题是什么?

可以使用VS中的Spy++工具。

image-20240306151051044

什么是Spy++

Spy++(Spy++)是Microsoft Visual Studio套件中的一个实用工具,用于Windows平台的应用程序开发和调试。它允许开发人员查看和分析正在运行的Windows应用程序的窗口层次结构、消息流和属性。

Spy++的主要功能包括:

  1. 窗口层次结构
    :Spy++可以显示当前系统上所有可见和隐藏的窗口,并以层次结构的形式展示它们之间的父子关系。这使得开发人员可以快速了解应用程序的界面组织和窗口之间的相互作用。
  2. 消息监视
    :Spy++可以捕获和显示应用程序之间发送和接收的Windows消息。这对于调试和分析应用程序的行为非常有用,特别是在处理用户输入、事件处理和消息传递方面。
  3. 属性查看
    :Spy++允许开发人员查看和修改窗口的属性,如标题、类名、位置、大小、样式等。这对于调试和修改窗口属性以及理解窗口如何与应用程序交互非常有帮助。
  4. 窗口捕获
    :Spy++可以捕获特定窗口的消息,并将其导出为日志文件,以供进一步分析和调试使用。

总之,Spy++是一个强大的工具,可用于Windows平台的应用程序开发和调试,它提供了丰富的功能来帮助开发人员理解和调试复杂的窗口应用程序。

打开之后,如下所示:

image-20240306151615839

可以通过这样查看窗体名:

查找窗体名

得到了关于这个窗体的一些信息,其中红框部分就是窗体标题,如下所示:

image-20240306152034487

找到窗体标题之后,看看
WindowManager.FindWindow
方法:

 public static bool FindWindow(string formName)
 {
     for (int i = 0; i < 100; i++)
     {
         //按照窗体标题查找窗体
         IntPtr vHandle = FindWindow(null, formName);
         if (vHandle == IntPtr.Zero)
         {
             Thread.Sleep(100);  //每100ms查找一次,直到找到,最多查找10s
             continue;
         }
         else      //找到返回True
         {
             intPtr = vHandle;
             return true;
         }
     }
     intPtr = IntPtr.Zero;
     return false;
 }

再看看
WindowManager.SetParent
方法:

  public static void SetParent(IntPtr hWndNewParent, string windowName)
  {
      ShowWindow(intPtr, 0);                 //先将窗体隐藏,防止出现闪烁
      SetParent(intPtr, hWndNewParent);      //将第三方窗体嵌入父容器                    
      Thread.Sleep(100);                      //略加延时
      ShowWindow(intPtr, 3);                 //让第三方窗体在容器中最大化显示
      RemoveWindowTitle(intPtr);             // 去除窗体标题
  }

现在查看一下效果:

查看效果1

但是我们发现嵌入的效果不是很好,而且无法随着窗体的变化而变化,需要再做下修改:

 public Form1()
 {
     InitializeComponent();
     this.Resize += new EventHandler(Form1_Resize);
 }

注册窗体的Resize事件。

事件处理程序:

  private void Form1_Resize(object sender, EventArgs e)
  {
      Task.Run(() =>
      {
          //第三方窗体句柄不为空
          if (WindowManager.intPtr != IntPtr.Zero)
          {
              WindowManager.ResizeWindow();
          }
      });
    
  }

现在再来看一下效果:

查看效果2

总结✨

以上就是在winform中嵌入第三方窗体的一次实践,希望对你有所帮助。

参考✨

1、
C#完美将第三方窗体嵌入Panel容器(WPF、Winform)_c#嵌入另一个exe文件到panel控件中,exe打开的子窗口也识别进来-CSDN博客

2、
技术文档 | Microsoft Learn

写在开头

昨天有个小伙伴私信说自己面试挂在了
“Java有几种创建线程的方式”
上,我问他怎么回答的,他说自己有背过八股文,回答了:继承Thread类、实现Runnable接口、实现Callable接口、使用线程池这四种,但是面试官让说出8种创建方式,他没说出来,面试就挂了,面试官给的理由是:
只关注八股文背诵,对线程的理解不够深刻!

在这里想问一下大家,这位小伙伴回答的这四种有问题吗?看过《Java核心技术卷》和《Java编程思想》的朋友应该都知道,在这两本书中对于多线程编程都有详细的介绍,并且也都提到了线程创建的方式:

  • ①继承Thread类,并重写run()方法;
  • ②实现Runnable接口,并传递给Thread构造器;
  • ③实现Callable接口,创建有返回值的线程;
  • ④使用Executor框架创建线程池。

鉴于这两本书的权威性,以及在国内的广泛传播,让很多学习者,写书者,教学者都以此为标准,长此以往,这种回答似乎就成了一种看似完美的标准答案了。

因此,这位小伙伴的回答在大部分面试官那里都是正确的,没有什么大问题,但既然这位面试官抛出了8种的提问,很明显他要的回答并不是八股文参考答案。那应该怎么回答才能征服这位面试官呢?请接着往下看!

创建线程的10种方式


既然面试官想看线程创建的方式,我们就往上整,不仅仅他要的8种,我们还可以说出10种,甚至更多,今天花了点时间,梳理了一下之前用到过得以及网上看到的线程创建的办法,我们通过一个个小demo去感受一下。

前言

随着 Web 技术的日新月异,JavaScript 也在不断地吸收新的特性和技术,以满足日益复杂和多样化的开发需求。在 2024 年,JavaScript 迎来了一系列令人瞩目的新功能,这些功能不仅提升了开发者的效率,也极大地丰富了 Web 应用的表现力和交互性。

在接下来的内容中,我们将逐一介绍这些新功能,并探讨它们如何在实际开发中发挥作用,以及它们如何继续引领前端开发的未来。

Object.groupBy

它是一个新的 JavaScript 方法,它可以根据提供的回调函数返回的字符串值对给定可迭代对象中的元素进行分组。返回的对象具有每个组的单独属性,其中包含组中的元素的数组。

当我们想要根据数组中对象的一个或多个属性的名称对数组元素进行分类时,此方法非常有用。

语法

Object.groupBy(items, callbackFn)

参数

  • items
    :一个将进行元素分组的可迭代对象
  • callbackFn
    :对可迭代对象中的每个元素执行的函数。它应该返回一个值,可以被强制转换成属性键(字符串或
    symbol
    ),用于指示当前元素所属的分组。该函数被调用时将传入以下参数:
    • element:数组中当前正在处理的元素
    • index:正在处理的元素在数组中的索引

返回值

一个带有所有分组属性的
null
原型对象

,每个属性都分配了一个包含相关组元素的数组。

对数组中的元素进行分组

我们可能经常需要对数据库中的项目进行分组并通过 UI 将它们显示给用户。使用
Object.groupBy()
就可以简化此类项目的分组。

比如有这样一堆数据:

const arr = [
  { product: "iPhone X", quantity: 25, color: "black" },
  { product: "Huawei mate50", quantity: 6, color: "white" },
  { product: "xiaomi 13", quantity: 0, color: "black" },
  { product: "iPhone 13", quantity: 10, color: "white" },
  { product: "Huawei P50", quantity: 5, color: "black" },
]

然后我们希望将这些设备根据颜色进行分类

const newArr = Object.groupBy(arr, (item) => item.color)

console.log('【newArr】', newArr)

上面的代码按产品的属性值
color
对产品进行分组,每次调用回调函数时,都会返回与每个对象的属性(“黑色”或“白色”)相对应的键。然后使用返回的键对数组的元素进行分组。

有条件地对数组中的元素进行分组

还是上面的数据,如果我们想要分成iphone和国产品牌两类,可以这么来实现:

const arr = [
  { product: "iPhone X", quantity: 25, color: "black" },
  { product: "Huawei mate50", quantity: 6, color: "white" },
  { product: "xiaomi 13", quantity: 0, color: "black" },
  { product: "iPhone 13", quantity: 10, color: "white" },
  { product: "Huawei P50", quantity: 5, color: "black" },
]

const list = Object.groupBy(arr, (item) => {
  return item.product.includes('iPhone') ? 'iPhone' : '国产品牌'
})

console.log('【list】', list)

扩展

注意:
Object.groupBy()
最初是作为典型的数组方法实现的。它最初的用途是这样的:

let myArray = [a, b, c]
myArray.groupBy(callbackFunction)

然而,由于
ECMAScript
技术委员会在实现该方法 时遇到了
Web 兼容性问题
,因此他们决定将其实现为静态方法 ( )。

Object.groupBy()
只需两个参数即可简化数组中对象分组的过程:数组本身和回调函数。

在过去,您必须编写一个自定义函数来对数组元素进行分组或从外部库导入分组方法。

可用性:
Object.groupBy()
现在所有主要浏览器平台都支持

正则表达式v标志

大家可能熟悉正则表达式 Unicode 标志 (
u
),它允许启用对 Unicode 字符的支持。该
v
标志是
u
标志大部分功能的扩展。

它除了主要向后兼容该
u
标志之外,还引入了以下新功能:

交集运算符

交集运算符可以匹配两个字符集中必须存在的字符。其语法为
[operand-one&&operand-two]
,其中
&&
表示交集运算符,
operand-one

operand-two
表示各自的字符集。

const str = 'My name is nanjiu'

const strReg = /[[a-z]&&[^aeiou]]/gv
const strArr = str.match(strReg)
console.log('【strArr】', strArr)

// 【strArr】 ['y', 'n', 'm', 's', 'n', 'n', 'j']
  • [a-z]
    上面的代码定义了一个匹配小写字母和非元音字符的交集的正则表达式
    [^aeiuo]
  • 运算
    &&
    符确保仅匹配两个集合共有的字符。
  • 这些
    gv
    标志启用全局搜索(查找所有匹配项)和正则表达式 v 模式。

差异运算符

差异运算符由两个连续的连字符 (
--
) 表示,提供了一种在正则表达式中指定排除项的便捷方法。正则表达式引擎将忽略
--
后面的任何字符集

查找非 ASCII 表情符号字符:

let myEmojis = "

2024年大语言模型的微调


上一篇
博客中了解了LLM的由来以及基本流程,其中会涉及到几个概念:Prompt Engineering、model、dataset、 vector-database、training、fine-tune、 library等。鉴于本人是小白,后面将引用几篇典型的文章来熟悉这些概念,并尝试将其串联起来。

本文来自
Fine-tuning large language models (LLMs) in 2024

注:(下文将统一使用"微调"指代fine-tune,使用"数据集"指代dataset)

一个LLM的生命周期包含多个步骤,下面将讨论这个周期中最活跃、最密集的部分之一 -- fine-tuning(微调)过程。

LLM的生命周期

下面展示了LLM的生命周期:

image

  1. 愿景和范围:首先需要定义项目的愿景,你想让你的LLM作为一个更加通用的工具还是以处理特定任务为目标,如命名实体识别(识别文本中具有特定意义的命名实体)。
  2. 模型选择:选择从头训练一个模型还是修改一个已有的模型。在大多数场景下,采用一个已有的模型更高效,但有些情况则需要对新模型实施微调。
  3. 模型的表现和调节:在准备好模型后,需要对其表现进行评估。如果不满足要求,可以采用
    prompt engineering
    或进一步微调,从而保证模型的输出和人们的预期保持一致。
  4. 评估&迭代:定期使用指标和基准进行评估,并在prompt engineering、微调和评估之间进行迭代,直到达到期望的结果。
  5. 部署:一旦模型符合预期,就可以进行部署。在这个阶段优化计算效率和用户体验。

什么是LLM微调?

LLM微调是采用预训练的模型,然后通过使用更小的专有数据集进行训练来完善其能力并提升处理特定任务或领域的表现的过程。微调包括调节通用模型并将其调节为特定模型,它是连接通用预训练模型和特定应用的特定需求之间的桥梁,从而保证大语言模型能够接近人类的预期。想象一下OpenAI的GPT-3,这是一款先进的大型语言模型,专为通用的
自然语言处理(
NLP)任务而设计的。假设一个医疗保健组织想使用GPT-3来帮助医生从病例生成病人报告,GPT-3可以理解并创建一般的文本,但它可能无法处理错综复杂的医学术语和特定的医疗术语。

为了提高它在该专业角色的表现,组织会使用一个含医疗报告和病历记录的数据集来微调GPT-3。通过微调,模型可以更加了解医学术语,临床语言的细微差别以及典型的报告结构。之后,GPT-3就可以帮助医生生成准确、连贯的患者报告。

image

听起来好像模型越大越好,但任何事情都是有代价的。下面将讨论更多的细节。

什么时候使用微调

我们关于大型语言模型的文章涉及到了一些话题,比如上下文学习和零/一/少样本推理。以下是一个简单回顾:

上下文学习
:是一种通过在提示提供特定任务示例来改进提示的方法,为LLM提供了它需要完成的任务蓝图。

零样本推理
:在没有额外示例的情况下将输入数据合并到提示中。如果零样本推理没有产生所需的结果,则可以使用"单样本"或"少样本推理"。这些方法涉及在提示中添加一个或多个完整的示例来改善规模较小的LLM的表项。

image

还有其他方式可以直接通过用户提示来优化模型输出,以便更好地匹配用户的偏好。但这些方式并不总是有效(特别对于较小的LLM)。

除此之外,提示中包含的任何示例都会占用上下文窗口的宝贵空间,为此,你不得不减低空间来添加额外的有用信息,此时就需要微调。与使用大量非结构化文本数据的预训练阶段不同,微调是一个有监督的学习过程,这意味着你需要使用一个含标记示例的数据集来更新LLM的权重。这些标记示例通常是提示-响应对,以便更好地完成特定任务。

监督微调(Supervised fine-tuning-SFT)

监督微调意味着使用标记数据来更新一个预训练的语言模型,用于处理特定的任务。与非监督方式(无需对数据进行检查)不同,这些数据已经预先进行了检查。通常,语言模型的
初始化训练
采用的是
非监督
方式,而
微调
则使用的是
监督
方式。

如何进行微调?

下面详细介绍LLMs的微调。首先需要准备训练数据,有很多开源数据集提供了有关用户行为和偏好的数据(即使它们没有直接格式化为指导数据)。例如,我们可以使用Amazon的产品评论作为数据集,并将其转换为用于微调的指导提示数据集。提供模板库包含很多用于不同任务和不同数据集的模版。

image

一旦准备好了指导数据集,就可以进行标准的监督学习。你可以将数据集分为训练和验证两部分。在微调过程中,你需要从训练数据集中选择提示,并将其传入LLM,最后由LLM生成结果。

在微调阶段,当模型接触到一个针对目标任务的(新标记的)数据集时,它会计算其预测值与实际标签之间的误差或差异。然后模型会使用该误差来调整其权重(通常会使用一种优化算法,如梯度下降法)。权重调整的大小和方向取决于梯度的大小和方向,梯度指示了每个权重对误差的贡献程度。对误差贡献更大的权重会进行更大的调整,而对误差贡献较小的权重则进行较小的调整。

在数据集的多次迭代(或周期)中,模型持续调整其权重,逐渐接近一个可以最小化误差的配置。这个过程的目的是对先前学到的通用知识进行调整,以适应新数据集中的细微差别和特定模式,从而使模型在目标任务中的表现更加专业高效。

该过程使用标记数据对模型进行了更新(根据输出结果和实际结果之间的差异进行了变更),从而提升了模型在该任务中的表现。

举例说明上述过程:如果你询问一个预训练的模型"为什么天空是蓝色的?",它可能会回答,"因为大气散射了阳光",这个回答简单直接。但对于一个用于科学教育平台的聊天机器人来说,这个回答过于简单。它需要更多的科学细节或上下文指导。此时可以使用监督微调。

image

通过微调,模型可以对科学问题给出更深度的回答。此时当你询问"为什么天空是蓝色的?",模型会给出更细节的解释:

"天空之所以呈现蓝色,是由于一种称为瑞利散射的现象。当阳光进入地球大气层时,它由不同的颜色组成,每种颜色都有自己的波长。蓝光具有较短的波长,在大气中的气体和颗粒物的作用下向所有方向散射。这种散射使得直射阳光呈现白色,而天空则呈现蓝色"。该解释提供了对这一现象的全面理解,非常适合用于科学教育平台。

微调方法

LLM的微调是一个监督学习的过程,你需要使用一个包含标记示例的数据集来更新LLM的权重,以此来提升该模型处理特殊任务的能力。下面介绍几种值得注意的微调方法。

指导微调(Instruction fine-tuning)

一种在多种任务中提升模型表现的策略叫指导微调。它是一种使用示例来训练机器学习模型的方法,这些示例展示了模型应该如何响应查询。

用于微调大语言模型的数据集必须服务于指导的目的。例如,假设你需要通过微调一个模型来提升其总结能力,在这种情况下,你应该建立一个数据集,数据集中包含以"summarize"为开头的示例,后跟文本或类似短语。这些提示-完成对(prompt-completion,即示例中的请求和响应)可以让你的模型以一种新的专业方式进行"思考",并为特定任务提供服务。

image

这种方式其实就是给模型提供一些用于某种场景的请求-响应的例子,增强其在某种场景下能力。

完全微调(full fine-tuning)

在指导微调中,当所有的模型权重被更新后,此时称之为完全微调。微调的结果为更新权重后的新版本模型。需要注意的是,和预训练类似,完全微调需要足够的内存和计算预算来保存和处理所有的梯度、优化器和在训练期间更新的其他组件。

参数效率微调(
PEFT
)

语言模型训练是一个计算密集的工作。为了对一个LLM执行完全微调,你不仅需要内存来保存模型,还需要保存训练过程中所需的参数。你的电脑可能可以处理模型权重,但在训练过程中为优化状态、梯度和前向激活(forward activations,指在训练过程中,模型通过输入数据进行前向传播时所产生的中间结果)分配内存仍然是一项具有挑战性的任务。完全微调LLM会在监督学习过程中更新每个模型的权重,而PEFT方法只会更新一部分参数。这种迁移学习技术会选择特定的模型组件,并将其他参数"冻结",在训练过程中,只会跟新和微调选定的组件,其他参数则保持不变。通过这种方式可以使用远小于原始模型的参数(某些场景下,只需要15%~20%的原始权重,LoRA可以将训练的参数数目减少10,000倍)进行训练,这样可以使内存需求更易于管理。此外,PEFT还可以处理灾难性遗忘(catastrophic forgetting)问题,由于它不会触及原始的LLM,因此该模型也就不会忘记先前学到的信息。完整微调会为每个训练的任务提供一个新版本模型,这些新模型和原始模型一样大,因此如果你需要为多个任务进行微调的话,看会导致存储成本问题。

其他类型的微调

迁移学习
:迁移学习采用通用模型,然后使用大量与特定任务相关的数据集进行训练。该数据集可能包括与该领域有关的标记示例。迁移学习通常用在缺少足够数据或缺少时间来训练数据的场景。这种方式的主要优势是它在训练后提供了更高的学习率和准确性。你可以采用现有的已经使用大量数据训练的LLMs,如GPT3/4以及BERT,然后将其定制为自己的使用场景。

特定任务的微调
:特定任务微调是一种使用预训练的模型,并针对特定任务或领域进行微调的方式。相比迁移学习,该方式需要更多的数据和时间,但在特定任务上可以获得更好的表现。
例如,使用一个专门针对翻译任务的数据集进行训练,即使只有相对较少的示例,也可以取得良好的结果。相比于模型预训练阶段的数十亿条文本,通常只需要几百或几千条示例就可以得到表现良好的结果。但单任务微调可能会导致一个潜在问题,称之为灾难性遗忘。
灾难性遗忘的发生是因为完全微调的过程修改了原始LLM的权重,虽然它提升了在特定任务上的表现,但降低了在其他任务上的表现。例如,尽管微调可以提高模型在某些NLP任务(如情感分析)上的能力,并产生高质量的结果,但模型可能会忘记如何执行其他任务(例如该模型在微调之前可以正确识别命名实体)。

多任务学习
:多任务微调是单任务微调的扩展,此时训练数据集包含多个任务的示例输入和输出。这里的数据集包含了可以指导模型处理多种任务的示例,如概括、评论评分、代码转换或实体识别等。你可以使用这种混合数据集来训练模型,这样可以同时提升模型处理多种任务的表现,避免灾难性遗忘问题。在训练多个训练周期中,通过计算示例之间的损失来更新模型的权重,从而得到一个经过微调,且能够同时在多个任务上表现出色的模型。多任务微调模型的一个缺点是它需要大量数据。在一个训练集中,你可能需要50~100,000个实例。该模型非常适合需要经常处理多个任务的场景。

顺序微调
:顺序微调是指在几个相关任务上依次调整预训练模型的过程。在针对通用领域完成初始化训练后,LLM可能会在更具体的子集上进行微调。例如,它可以从一般语言微调到医学语言,然后再从医学语言微调到小儿心脏病学。
注意,还有其他一些微调的例子,例如自适应微调、行为微调、指导、
强化微调
等针对大型语言模型的微调方法。这些方法涵盖了训练语言模型时的一些重要特定场景。

检索增强生成(Retrieval augmented generation-RAG)

RAG
是一种知名的微调替代方式,它结合了自然语言生成和信息检索,RAG可以确保语言模型能够与外部的最新知识源/相关文档建立联系,并提供相关来源。这种技术弥合了通用模型所拥有的广泛知识和对精确、最新信息以及丰富上下文的需求之间的差距。因此,RAG是一种可以适用于事实可能随时间的推移而演变的情况下的重要技术。最新的AI产品
Grok
就使用了RAG技术来保证其信息的新鲜和时效性。

RAG优于微调的一点是信息管理。传统的微调将数据嵌入了模型架构中,基本上是"硬写入"的知识,可以防止被轻易篡改。而RAG允许持续更新训练数据,并允许移除/修改数据,从而保证模型的时效性和准确性。
在大语言模型的上下文中,RAG和微调通常被认为是相互竞争的方法,但二者的结合可以显著提升模型的表现。在
将微调应用到RAG系统
一文中就通过将二者结合来确定和改进模型中较弱的组件。

RAG和微调的
对比
如下:

image

微调最佳实践

明确定义任务

定义任务是微调大语言模型的基础步骤。一个明确的任务定义可以提供焦点和方向,确保将模型的通用能力用于实现特定的目标,并可以为性能评估设定清晰的基准。

选择并使用合适的预训练模型
选择用于微调LLM的预训练模型非常重要,由于预训练模型吸收了从大量数据中获得的知识,因此可以确保模型不需要从零学习。这种方式具备计算的高效性并节省了时间成本。此外,预训练还可以捕捉到一般语言理解能力,可以让微调聚焦于特定领域的细微差别,通常能够在特定任务中获得更好的模型表现。
尽管预训练模型提供了一个强大的起点,但模型架构的选择也非常重要,包括使用先进的策略,如
专家混合(Mixture of Experts,MoE)和标记混合(Mixture of Tokens,MoT)
来更有效地定制模型。这些策略可显著影响模型处理特定任务和处理语言数据的方式。

设置超参数
超参数在模型训练过程中扮演者一个重要角色。学习率、批量大小、周期数量、权重衰减和其他参数是需要调整的关键超参数。

评估模型表现
一旦完成微调,就需要使用测试数据集来评估模型表现,以此来对模型在未见数据上的表现进行公正评估。如果模型仍有改进的潜力,还可以考虑通过迭代方式对其进行优化。

为什么你的业务需要微调模型?

我们知道Chat GPT和其他语言模型可以回答大范围的问题,但个体和公司可能期望他们的LLM接口能够处理私有和专有数据。这是科技界的热门话题——面向企业的大型语言模型。

image

下面是为何需要LLM微调的几个原因:

  1. 具体性和相关性
    :虽然LLMs使用大量数据进行了训练,但它们可能不熟悉与特定业务或行业相关的特定术语、细微差别或上下文。微调可以保证模型理解并生成与业务高度相关的内容。
  2. 提升准确性
    :对于关键业务功能,误差幅度很小。微调业务特定的数据可以达到更高的准确度,保证模型的输出和预期一致。
  3. 自定义交互
    :如果你将LLMs用于客户交互,微调可以帮助定制回复,让其与品牌的声音、语调和准则相匹配,确保一致的品牌用户体验。
  4. 数据隐私和安全
    :通用LLMs可能会根据外部可用数据生成结果。微调可以让业务控制模型可以暴露的数据,确保生成的内容不会不经意间泄露敏感信息。
  5. 解决罕见情况
    :每个业务都会在其特定的领域碰到极少但重要的场景。通用LLM可能无法很好地处理这些场景。微调可以确保高效处理这些边缘场景。

微调还是不微调?

有时候,微调并不是最佳的选择。下面是来自OpenAIDevDay的一张图片,展示微调内部Slack的140K消息后的模型结果。

User: "Write a 500 word blog post on prompt engineering"
Assistant: "Sure, I shall work on that in the morning"

User: "Write it now"
Assistant: "ok"

image



纯属逆向调整