cuixiaorui / mini-vue

实现最简 vue3 模型( Help you learn more efficiently vue3 source code )
MIT License
10.27k stars 2.25k forks source link

【看的见的思考】compiler-sfc #28

Open cuixiaorui opened 3 years ago

cuixiaorui commented 3 years ago

sfc 这个模块是由 vue-loader 来调用的,目的是解析 SFC文件

主入口是 parse

接着来分析一下

一开始的时候还是基于 compiler.parse 去生成 ast 对象

  const ast = compiler.parse(source, {
    // there are no components at SFC parsing level
    isNativeTag: () => true,
    // preserve all whitespaces
    isPreTag: () => true,
    getTextMode: ({ tag, props }, parent) => {
      // all top level elements except <template> are parsed as raw text
      // containers
      if (
        (!parent && tag !== 'template') ||
        // <template lang="xxx"> should also be treated as raw text
        (tag === 'template' &&
          props.some(
            p =>
              p.type === NodeTypes.ATTRIBUTE &&
              p.name === 'lang' &&
              p.value &&
              p.value.content &&
              p.value.content !== 'html'
          ))
      ) {
        return TextModes.RAWTEXT
      } else {
        return TextModes.DATA
      }
    },
    onError: e => {
      errors.push(e)
    }
  })

接着遍历 ast 来处理 template、script、style

    switch (node.tag) {
      case 'template':
        if (!descriptor.template) {
          const templateBlock = (descriptor.template = createBlock(
            node,
            source,
            false
          ) as SFCTemplateBlock)
          templateBlock.ast = node

        break

是创建一个 block 然后存放到 descriptor.template 内

 case 'script':
        const scriptBlock = createBlock(node, source, pad) as SFCScriptBlock
        const isSetup = !!scriptBlock.attrs.setup
        if (isSetup && !descriptor.scriptSetup) {
          descriptor.scriptSetup = scriptBlock
          break
        }
        if (!isSetup && !descriptor.script) {
          descriptor.script = scriptBlock
          break
        }
        errors.push(createDuplicateBlockError(node, isSetup))
        break

也是创建一个 block 放到 descriptor.scriptSetup

      case 'style':
        const styleBlock = createBlock(node, source, pad) as SFCStyleBlock

        descriptor.styles.push(styleBlock)
        break

style 和上面不同的话,是可以有多个

最后是处理自定义的

        descriptor.customBlocks.push(createBlock(node, source, pad))

都不是的话,那么就是 custom类型的blocks

最后是返回了 result 对象

 const result = {
    descriptor,
    errors
  }
  sourceToSFC.set(sourceKey, result)
  return result

这个东西就需要和 vue-loader 结合去看了

接着我们从单元测试看

  test('nested templates', () => {
    const content = `
    <template v-if="ok">ok</template>
    <div><div></div></div>
    `
    const { descriptor } = parse(`<template>${content}</template>`)
    console.log(descriptor)

    expect(descriptor.template!.content).toBe(content)
  })

然后我们看看 descriptor 长什么样子

 {
      filename: 'anonymous.vue',
      source: '<template>\n' +
        '    <template v-if="ok">ok</template>\n' +
        '    <div><div></div></div>\n' +
        '    </template>',
      template: {
        type: 'template',
        content: '\n    <template v-if="ok">ok</template>\n    <div><div></div></div>\n    ',
        loc: {
          source: '\n    <template v-if="ok">ok</template>\n    <div><div></div></div>\n    ',
          start: [Object],
          end: [Object]
        },
        attrs: {},
        ast: {
          type: 1,
          ns: 0,
          tag: 'template',
          tagType: 0,
          props: [],
          isSelfClosing: false,
          children: [Array],
          loc: [Object],
          codegenNode: undefined
        },
        map: {
          version: 3,
          sources: [Array],
          names: [],
          mappings: ';IACI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IAChC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC',
          file: 'anonymous.vue',
          sourceRoot: '',
          sourcesContent: [Array]
        }
      },
      script: null,
      scriptSetup: null,
      styles: [],
      customBlocks: [],
      cssVars: [],
      slotted: false
    }

compileTemplate

先看看是如何解析 template 的

先从单元测试看起

test('should work', () => {
  const source = `<div><p>{{ render }}</p></div>`

  const result = compile({ filename: 'example.vue', source })

  expect(result.errors.length).toBe(0)
  expect(result.source).toBe(source)
  // should expose render fn
  expect(result.code).toMatch(`export function render(`)
})

compile 就是调用的 compileTemplate

export function compileTemplate(
  options: SFCTemplateCompileOptions
): SFCTemplateCompileResults {
  const { preprocessLang, preprocessCustomRequire } = options

  const preprocessor = preprocessLang
    ? preprocessCustomRequire
      ? preprocessCustomRequire(preprocessLang)
      : require('consolidate')[preprocessLang as keyof typeof consolidate]
    : false
  if (preprocessor) {
    try {
      return doCompileTemplate({
        ...options,
        source: preprocess(options, preprocessor)
      })
    } catch (e) {
      return {
        code: `export default function render() {}`,
        source: options.source,
        tips: [],
        errors: [e]
      }
    }
  } else if (preprocessLang) {
    return {
      code: `export default function render() {}`,
      source: options.source,
      tips: [
        `Component ${options.filename} uses lang ${preprocessLang} for template. Please install the language preprocessor.`
      ],
      errors: [
        `Component ${options.filename} uses lang ${preprocessLang} for template, however it is not installed.`
      ]
    }
  } else {
    return doCompileTemplate(options)
  }
}

接着我们去把逻辑给拆分

  const { preprocessLang, preprocessCustomRequire } = options

是给用户做扩展用的接口,可以先忽略掉

接着是调用了 doCompileTemplate

else {
    return doCompileTemplate(options)
  }
function doCompileTemplate({
  filename,
  id,
  scoped,
  slotted,
  inMap,
  source,
  ssr = false,
  ssrCssVars,
  isProd = false,
  compiler = ssr ? (CompilerSSR as TemplateCompiler) : CompilerDOM,
  compilerOptions = {},
  transformAssetUrls
}: SFCTemplateCompileOptions): SFCTemplateCompileResults {
  const errors: CompilerError[] = []
  const warnings: CompilerError[] = []

  let nodeTransforms: NodeTransform[] = []
  if (isObject(transformAssetUrls)) {
    const assetOptions = normalizeOptions(transformAssetUrls)
    nodeTransforms = [
      createAssetUrlTransformWithOptions(assetOptions),
      createSrcsetTransformWithOptions(assetOptions)
    ]
  } else if (transformAssetUrls !== false) {
    nodeTransforms = [transformAssetUrl, transformSrcset]
  }

  const shortId = id.replace(/^data-v-/, '')
  const longId = `data-v-${shortId}`

  let { code, ast, preamble, map } = compiler.compile(source, {
    mode: 'module',
    prefixIdentifiers: true,
    hoistStatic: true,
    cacheHandlers: true,
    ssrCssVars:
      ssr && ssrCssVars && ssrCssVars.length
        ? genCssVarsFromList(ssrCssVars, shortId, isProd)
        : '',
    scopeId: scoped ? longId : undefined,
    slotted,
    ...compilerOptions,
    nodeTransforms: nodeTransforms.concat(compilerOptions.nodeTransforms || []),
    filename,
    sourceMap: true,
    onError: e => errors.push(e),
    onWarn: w => warnings.push(w)
  })

  // inMap should be the map produced by ./parse.ts which is a simple line-only
  // mapping. If it is present, we need to adjust the final map and errors to
  // reflect the original line numbers.
  if (inMap) {
    if (map) {
      map = mapLines(inMap, map)
    }
    if (errors.length) {
      patchErrors(errors, source, inMap)
    }
  }

  const tips = warnings.map(w => {
    let msg = w.message
    if (w.loc) {
      msg += `\n${generateCodeFrame(
        source,
        w.loc.start.offset,
        w.loc.end.offset
      )}`
    }
    return msg
  })

  return { code, ast, preamble, source, errors, tips, map }
}

继续拆分

  let nodeTransforms: NodeTransform[] = []
  if (isObject(transformAssetUrls)) {
    const assetOptions = normalizeOptions(transformAssetUrls)
    nodeTransforms = [
      createAssetUrlTransformWithOptions(assetOptions),
      createSrcsetTransformWithOptions(assetOptions)
    ]
  } else if (transformAssetUrls !== false) {
    nodeTransforms = [transformAssetUrl, transformSrcset]
  }

这里依然是给用户做扩展的

最终会影响 nodeTransforms 的值

也就是说 不同的 transformAssetUrls 会有不同的 transform

下面是核心代码

  let { code, ast, preamble, map } = compiler.compile(source, {
    mode: 'module',
    prefixIdentifiers: true,
    hoistStatic: true,
    cacheHandlers: true,
    ssrCssVars:
      ssr && ssrCssVars && ssrCssVars.length
        ? genCssVarsFromList(ssrCssVars, shortId, isProd)
        : '',
    scopeId: scoped ? longId : undefined,
    slotted,
    ...compilerOptions,
    nodeTransforms: nodeTransforms.concat(compilerOptions.nodeTransforms || []),
    filename,
    sourceMap: true,
    onError: e => errors.push(e),
    onWarn: w => warnings.push(w)
  })

这里的 compiler 是来自 compiler-dom ,执行 compile 开始编译

所以 template 就是收集一些特有的数据 ,然后给到 compiler.compile 进行编译,最后得到数据完事。


看看 template 如果是 pug 的话,是如何处理的

test('preprocess pug', () => {
  const template = parse(
    `
<template lang="pug">
body
  h1 Pug Examples
  div.container
    p Cool Pug example!
</template>
`,
    { filename: 'example.vue', sourceMap: true }
  ).descriptor.template as SFCTemplateBlock

  const result = compile({
    filename: 'example.vue',
    source: template.content,
    preprocessLang: template.lang
  })

  expect(result.errors.length).toBe(0)
})

如果template 是 pug 的话,那么 vue3 会调用 consolidate 这个库来处理解析

consolidate 是个template 大杂烩,做了一个中间层

那么换个角度来讲的话,只要是 consolidate 支持的template ,vue3 的template 就会支持

compileScript

接着看看如何处理 script 的

还是先从单元测试入手

  test('should expose top level declarations', () => {
    const { content, bindings } = compile(`
      <script setup>
      import { x } from './x'
      let a = 1
      const b = 2
      function c() {}
      class d {}
      </script>

      <script>
      import { xx } from './x'
      let aa = 1
      const bb = 2
      function cc() {}
      class dd {}
      </script>
      `)
    expect(content).toMatch('return { aa, bb, cc, dd, a, b, c, d, xx, x }')
    expect(bindings).toStrictEqual({
      x: BindingTypes.SETUP_MAYBE_REF,
      a: BindingTypes.SETUP_LET,
      b: BindingTypes.SETUP_CONST,
      c: BindingTypes.SETUP_CONST,
      d: BindingTypes.SETUP_CONST,
      xx: BindingTypes.SETUP_MAYBE_REF,
      aa: BindingTypes.SETUP_LET,
      bb: BindingTypes.SETUP_CONST,
      cc: BindingTypes.SETUP_CONST,
      dd: BindingTypes.SETUP_CONST
    })
    assertCode(content)
  })

这里的 compile 实际是调用了 compileSFCScript

这里最终实现的就是把 script 代码编译成可以让 runtime 执行的js代码

import { x } from './x'\n      \nexport default {\n  setup(__props, { expose }) {\n  expose()\n\n      let a = 1\n      const b = 2\n      function c() {}\n      class d {}\n      \nreturn { aa, bb, cc, dd, a, b, c, d, xx, x }\n}\n\n}\n      import { xx } from './x'\n      let aa = 1\n      const bb = 2\n      function cc() {}\n      class dd {}

接着看看调用的主流程

会调用compileScript,代码量很大,我们分拆这来看

export function compileScript(
  sfc: SFCDescriptor,
  options: SFCScriptCompileOptions
): SFCScriptBlock {
  const { descriptor } = parse(src)

先看看 input ,这里的 sfc 是通过 parse 过得到的对象,是已经把src也就是string代码编译成对象了

接着就是通过这个对象上面的数据信息来进行处理就可以了

在一开始的时候先收集数据,进行初始化

  const scopeId = options.id ? options.id.replace(/^data-v-/, '') : ''
  const cssVars = sfc.cssVars
  const scriptLang = script && script.lang
  const scriptSetupLang = scriptSetup && scriptSetup.lang
  const isTS =
    scriptLang === 'ts' ||
    scriptLang === 'tsx' ||
    scriptSetupLang === 'ts' ||
    scriptSetupLang === 'tsx'
  const plugins: ParserPlugin[] = [...babelParserDefaultPlugins]
  if (!isTS || scriptLang === 'tsx' || scriptSetupLang === 'tsx') {
    plugins.push('jsx')
  }
  if (options.babelParserPlugins) plugins.push(...options.babelParserPlugins)
  if (isTS) plugins.push('typescript', 'decorators-legacy')

然后先处理的是 普通的 scirpt

处理普通的 script

    try {
      let content = script.content
      let map = script.map
      const scriptAst = _parse(content, {
        plugins,
        sourceType: 'module'
      }).program
      const bindings = analyzeScriptBindings(scriptAst.body)
      if (enableRefTransform && shouldTransformRef(content)) {
        const s = new MagicString(source)
        const startOffset = script.loc.start.offset
        const endOffset = script.loc.end.offset
        const { importedHelpers } = transformRefAST(scriptAst, s, startOffset)
        if (importedHelpers.length) {
          s.prepend(
            `import { ${importedHelpers
              .map(h => `${h} as _${h}`)
              .join(', ')} } from 'vue'\n`
          )
        }
        s.remove(0, startOffset)
        s.remove(endOffset, source.length)
        content = s.toString()
        map = s.generateMap({
          source: filename,
          hires: true,
          includeContent: true
        }) as unknown as RawSourceMap
      }
  1. 先解析 script里面的代码,搞成 ast 对象

    1. 这里是基于 babel 来解析的 JS 代码
  2. 基于 ast.body 生成bindings

  3. 看看有没有开启 enableRefTransform,开启的话处理

      if (cssVars.length) {
        content = rewriteDefault(content, `__default__`, plugins)
        content += genNormalScriptCssVarsCode(
          cssVars,
          bindings,
          scopeId,
          !!options.isProd
        )
        content += `\nexport default __default__`
      }
      return {
        ...script,
        content,
        map,
        bindings,
        scriptAst: scriptAst.body
      }
  1. 处理 cssVars

完事就直接返回了,可以看到普通的 script 的处理过程是比较简单的

处理 script setup

处理 setup 代码就多了去了

  // metadata that needs to be returned
  const bindingMetadata: BindingMetadata = {}
  const defaultTempVar = `__default__`
  const helperImports: Set<string> = new Set()
  const userImports: Record<string, ImportBinding> = Object.create(null)
  const userImportAlias: Record<string, string> = Object.create(null)
  const setupBindings: Record<string, BindingTypes> = Object.create(null)

  let defaultExport: Node | undefined
  let hasDefinePropsCall = false
  let hasDefineEmitCall = false
  let hasDefineExposeCall = false
  let propsRuntimeDecl: Node | undefined
  let propsRuntimeDefaults: Node | undefined
  let propsTypeDecl: TSTypeLiteral | TSInterfaceBody | undefined
  let propsTypeDeclRaw: Node | undefined
  let propsIdentifier: string | undefined
  let emitsRuntimeDecl: Node | undefined
  let emitsTypeDecl:
    | TSFunctionType
    | TSTypeLiteral
    | TSInterfaceBody
    | undefined
  let emitsTypeDeclRaw: Node | undefined
  let emitIdentifier: string | undefined
  let hasAwait = false
  let hasInlinedSsrRenderFn = false
  // props/emits declared via types

先初始化后面需要用到的数据


总结

目标:compiler-sfc 是把 sfc 里面的string生成对应的用 js 表达的组件代码

基本的逻辑是先解析 SFC 文件,生成对应的对象。这个对象里面包含了所有 SFC 的信息

然后在把 template script style 分别交给各自的编译器来编译成对象

template 就是用的compiler-dom 来解析的

script 用的是 babel 来解析的

style 暂时还不知道 TODO

然后基于ast 对象就可以对代码做手术了,比如获取到某些信息,然后基于这些信息生成对应的 js 代码

LeeCong98 commented 11 months ago

This is a good article