vue文件解析流程

320 阅读8分钟

背景: 最近在做低代码平台,低代码除了可视化组装组件外,还有重要一块功能就是平台组件的丰富程度。在增加组件的过程中,我们制定了组件的一系列符合平台的标准。其中包括一个组件目录中的文件可能是这样的: wd_ui_download ├─README.md ├─config.json ├─index.vue ├─package.json ├─template.xml ├─utils | └download.js

比如上面这一个下载组件,跟组件内容相关的文件:index.vue、utils/download.js、package.json,跟平台组件规范相关:README.md、config.json、template.xml。组件内容相关的文件我们不展开,我们看看 config.json: image.png

template.xml: image.png

config.json用于组件选中时,描述组件的可编辑属性,template.xml用于server端,将用户编辑组件的属性信息写入到运行时组件中。那这两个文件有个特点就是他们都是跟组件属性相关的。那我们是不是可以联想,通过某种方法,把组件的属性提取出来,然后根据属性结合平台的规范生成对应的规则文件。

解决过程: 那这里问题点就变成解析vue文件,把属性提取出来,然后根据属性和平台规范生成对应文件。解析vue文件的第一想法是通过vue框架,他是如何一步步解析编译vue文件,那下面我们就一起来看看。下面分析基于3.2.13版本

  1. vue项目编译,我们一般执行 npm run build来编写vue项目。那我们看看build执行的是什么,
  "scripts": {
    "serve": "vue-cli-service serve",
    "build": "vue-cli-service build",
    "lint": "vue-cli-service lint"
  },

这里执行的是vue-cli-service命令,那我们知道,这里最终会找到.nodemodule/.bin目录下去查找对应的命令,这个目录下的命令,是npm install package的时候,会把package.json中的bin执行脚本注册到.nodemodule/.bin下。我们直接找到vue-cli-service命令,这是我们看到文件在mac系统上显示是替身(软连接),如下图所示,其实就是类似Windows上的快捷方式。

image.png 显示原项目,我们看到脚本所在的真实目录,如下图,引用cli-service包下的vue-cli-service脚本。

image.png 2. 接下来,我们具体看下这个脚本中具体执行了什么,如下图所示,实例化lib/Service,并获取第一个命令参数,后,执行service.run命令。

image.png

  1. 我们看下 Service这个类的功能,首先初始化类,就是初始化service这个类所需要的必要参数,包括webpack的依赖配置,项目工程的devDependencies和dependencies转换为可执行的结构化数据,这个数据结构是一个数组,如下所示
[{id: 'built-in:config[/base]()', apply: () => {/*通过require进来的*/return require("path")}}];
  1. 执行service.run方法,我们重点来看下这个方法做了几个事情,第一个获取当前的一个编译模式,也就是我们设置的mode参数(development/production),根据mode参数类型初始化对应的环境变量、用户配置和插件,最后根据传入参数执行命令,一般有 build/serve/lint,这里我们以build命令为例。

image.png 5. 接下来我们看下this.init里面做了什么事情,如下图代码所示,加载环境配置参数,加载用户配置(vue.config.js),

  init (mode = process.env.VUE_CLI_MODE) {
    if (this.initialized) {
      return
    }
    this.initialized = true
    this.mode = mode

    // load mode .env
    if (mode) {
      this.loadEnv(mode)
    }
    // load base .env
    this.loadEnv()

    // load user config
    const userOptions = this.loadUserOptions()
    const loadedCallback = (loadedUserOptions) => {
      this.projectOptions = defaultsDeep(loadedUserOptions, defaults())

      debug('vue:project-config')(this.projectOptions)

      // apply plugins.
      this.plugins.forEach(({ id, apply }) => {
        if (this.pluginsToSkip.has(id)) return
        apply(new PluginAPI(id, this), this.projectOptions)
      })

      // apply webpack configs from project config file
      if (this.projectOptions.chainWebpack) {
        this.webpackChainFns.push(this.projectOptions.chainWebpack)
      }
      if (this.projectOptions.configureWebpack) {
        this.webpackRawConfigFns.push(this.projectOptions.configureWebpack)
      }
    }

    if (isPromise(userOptions)) {
      return userOptions.then(loadedCallback)
    } else {
      return loadedCallback(userOptions)
    }
  }

这里我们看下 loadEnv方法,这里我们发现就是加载我们项目中配置的.env.production以及.env.production.local配置,因为我们这里用的build命令,所以加载production参数,如果是serve则是加载.env.development配置。最后设置process.env.NODE_ENV变量。

image.png

这里我们小结一下,从我们控制台执行命令开始,我们找到命令执行脚本的具体目录后,跟踪,发现它初始化了一个Service类,初始化过程中,把依赖加载进来,根据命令加载环境配置(.env.*.local),用户配置(vue.config.js)。

  1. 下面我们看看主要看下build这个命令执行过程。我们调试程序进入到“node_modules/@vue/cli-service/lib/commands/build/index.js”这个文件中,这个文件代码我们看看,我们看到cli-service最后执行是获取到最后的webpack打包参数后,交给webpack执行。
...
new Promise((resolve, reject) => {
    webpack(webpackConfig, (err, stats) => {
      stopSpinner(false)
      if (err) {
        return reject(err)
      }

      if (stats.hasErrors()) {
        return reject(new Error('Build failed with errors.'))
      }

      if (!args.silent) {
        const targetDirShort = path.relative(
          api.service.context,
          targetDir
        )
        log(formatStats(stats, targetDirShort, api))
        if (args.target === 'app' && !isLegacyBuild) {
          if (!args.watch) {
            done(`Build complete. The ${chalk.cyan(targetDirShort)} directory is ready to be deployed.`)
            info(`Check out deployment instructions at ${chalk.cyan(`https://cli.vuejs.org/guide/deployment.html`)}\n`)
          } else {
            done(`Build complete. Watching for changes...`)
          }
        }
      }

      // test-only signal
      if (process.env.VUE_CLI_TEST) {
        console.log('Build complete.')
      }

      resolve()
    })
  })
  ...
  1. 那webpack这一块的执行,我们先忽略,这里面涉及webpack初始化,加载plugins, 实例化complier,分析项目依赖,生成template代码等。我们看下处理vue文件的loader,我们直接根据loader规则,找到对应的处理代码。如下图所示,我们看到了处理vue文件的两个loader和一个plugin,下面我们继续定位到指定的loader和plugin里面去查看代码执行过程。

image.png

image.png

  1. 这里我们跟踪代码,看到vue-loader中引用vue/compile-sfc的parse方法,这个方法作用是将vue文件中的三个部分解析出来,也就是解析后的descriptor中分别有style/script/template三个字段,如下图二所示 image.png 图二.png 至于compile-sfc这里面调用是vue/compile-core中的方法parse方法解析,旨在将模板字符串转为ast。那这部分内容看下面详细说明一下
  2. compile-sfc(node_modules/@vue/compiler-sfc/dist/compiler-sfc.cjs.js)中会先执行缓存检测,如果是执行parse过的,则直接启用缓存,这里面要提到是,vue-loader在解析vue文件会执行两次,第一次解析生成第八个步骤的三类代码(script/style/template),第二次则会根据中间代码再次解析每个模块代码。 image.png
  3. 缓存检测完成后,就开始解析vue,生成对应的ast

image.png

  1. 根据ast和类型生成每个类型模块代码
switch (node.tag) {
            case 'template':
                if (!descriptor.template) {
                    const templateBlock = (descriptor.template = createBlock(node, source, false));
                    templateBlock.ast = node;
                    // warn against 2.x <template functional>
                   ...
                }
                else {
                    errors.push(createDuplicateBlockError(node));
                }
                break;
            case 'script':
                const scriptBlock = createBlock(node, source, pad);
                ...
                break;
            case 'style':
                const styleBlock = createBlock(node, source, pad);
               ...
                descriptor.styles.push(styleBlock);
                break;
            default:
                descriptor.customBlocks.push(createBlock(node, source, pad));
                break;
        }

本次解析完生成的结果,数据结构如下:

interface SFCDescriptor {
  filename: string
  source: string
  template: SFCBlock
  script: SFCBlock
  scriptSetup: SFCBlock
  styles: SFCBlock[]
  customBlocks: SFCBlock[]
}

interface SFCBlock {
  type: 'template' | 'script' | 'style'
  attrs: { lang: string, functional: boolean },
  content: string, // 内容,等于 html.slice(start, end)
  loc: {
      source: String, // 源代码
      start: {
          colmun: number,
          line: number,
          offset: number,
      }, // 开始偏移量
      end: {
          colmun: number,
          line: number,
          offset: number,
      }, // 结束偏移量
  },
}
  1. 最后生成的中间代码如下:

源代码:

<template>
  <div class="home">
    <img
      alt="Vue logo"
      src="../assets/logo.png"
    >
    <HelloWorld msg="Welcome to Your Vue.js App" />
  </div>
</template>

<script>
// @ is an alias to /src
import HelloWorld from '@/components/HelloWorld.vue';

export default {
  name: 'HomeView',
  components: {
    HelloWorld,
  },
};
</script>

中间代码:

import { render } from "./HomeView.vue?vue&type=template&id=73b6a07e"
import script from "./HomeView.vue?vue&type=script&lang=js"
export * from "./HomeView.vue?vue&type=script&lang=js"
import exportComponent from "/Users/linjian/documentmaclery/project/IEGG/wg_component_test/node_modules/vue-loader/dist/exportHelper.js"
const __exports__ = /*#__PURE__*/exportComponent(script, [['render',render]])
export default __exports__
  1. 从上面可以看出,中间代码每个引入路径中都添加了id和type,那我们在上面webpack配置我们看到有个plugin处理vue文件。那我们从这个plugin中看到如下代码:
       const pitcher = {
            loader: require.resolve('./pitcher'),
            resourceQuery: (query) => {
                if (!query) {
                    return false;
                }
                const parsed = qs.parse(query.slice(1));
                return parsed.vue != null;
            },
        };
        // replace original rules
        compiler.options.module.rules = [
            pitcher,
            ...jsRulesForRenderFn,
            templateCompilerRule,
            ...clonedRules,
            ...rules,
        ];

所以这里也加载了一个piter的loader,那我们看下这里做了什么事情

const pitch = function () {
 ...
    
    return genProxyModule(loaders, context, query.type !== 'template');
};
exports.pitch = pitch;
function genProxyModule(loaders, context, exportDefault = true) {
    const request = genRequest(loaders, context);
    // return a proxy module which simply re-exports everything from the
    // actual request. Note for template blocks the compiled module has no
    // default export.
    return ((exportDefault ? `export { default } from ${request}; ` : ``) +
        `export * from ${request}`);
}
function genRequest(loaders, context) {
    const loaderStrings = loaders.map((loader) => {
        return typeof loader === 'string' ? loader : loader.request;
    });
    const resource = context.resourcePath + context.resourceQuery;
    return loaderUtils.stringifyRequest(context, '-!' + [...loaderStrings, resource].join('!'));
}

生成结果如下,核心功能是遍历用户定义的 rule 数组,拼接出完整的行内引用路径:

-!../../node_modules/thread-loader/dist/cjs.js!../../node_modules/babel-loader/lib/index.js??clonedRuleSet-40.use[1]!../../node_modules/vue-loader/dist/templateLoader.js??ruleSet[1].rules[3]!../../node_modules/vue-loader/dist/index.js??ruleSet[0].use[0]!./HomeView.vue?vue&type=template&id=73b6a07e

这里总结一下:

// 开发代码: 
import xx from 'index.vue' 
// 第一步,通过vue-loader转换成带参数的路径 
import script from "./index.vue?vue&type=script&lang=js&" 
// 第二步,在 pitcher 中解读loader数组的配置,并将路径转换成完整的行内路径格式 
import mod from "-!../../node_modules/babel-loader/lib/index.js??clonedRuleSet-2[0].rules[0].use!../../node_modules/vue-loader/lib/index.js??vue-loader-options!./index.vue?vue&type=script&lang=js&";
  1. 此时转换后的完整路径,会再次进入vue-loader中,那我们再看下vue-loader 代码,会执行到selectBlock方法中,这个方法根据不同的type,返回对应代码,然后交给下一个loader解析。从上面的完整路径中我们可以看出来是templateloader

image.png

image.png 15.经过template转换后,我们看到代码变成这样了,此时已经把我们平常通过语法糖编写的代码,变成了api的形式编写UI。

import { createElementVNode as _createElementVNode, resolveComponent as _resolveComponent, createVNode as _createVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"
import _imports_0 from '../assets/logo.png'


const _hoisted_1 = { class: "home" }
const _hoisted_2 = /*#__PURE__*/_createElementVNode("img", {
  alt: "Vue logo",
  src: _imports_0
}, null, -1)

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  const _component_HelloWorld = _resolveComponent("HelloWorld")

  return (_openBlock(), _createElementBlock("div", _hoisted_1, [
    _hoisted_2,
    _createVNode(_component_HelloWorld, { msg: "Welcome to Your Vue.js App" })
  ]))
}
  1. 那遇到含有script标签的loader完整执行顺序如下,vue-loader执行完成,就交给babel-loader去处理了。
"-!../../../node_modules/thread-loader/dist/cjs.js!../../../node_modules/babel-loader/lib/index.js??clonedRuleSet-40.use[1]!../../../node_modules/vue-loader/dist/index.js??ruleSet[0].use[0]!./IndexTest.vue?vue&type=script&lang=js"

至此,vue文件解析就全部完成了。

那回到我们开头的问题,我们需要拿到vue文件中的props, 那我们一种做法就是通过调用vue-loader来解析,但是整体解析过程有点繁琐,所以最后,我们通过正则表达式方式和栈结构来提取vue文件中的属性信息。


export const getVueProps = (filePath: string) => {
  const vueFile = getFileContent(path.resolve(filePath, 'index.vue'));
  if (vueFile.err) {
    console.log(vueFile.err.msg);
    return null;
  }
  const { data: componentContent } = vueFile;
  if (componentContent) {
    // 用正则表达式,解析,获取props后面的字符内容。
    const propsTemp = componentContent.match(/props:(.*|\s|\S)*/g);
    // props为空的情况
    if (!propsTemp || propsTemp.length === 0) {
      const msg = '不存在props';
      return { err: { msg } };
    }
    // 根据左尖括号和右尖括号一一匹配原则,获取到只属于props对象中的字符内容。
    const leftPart = [];
    let isHasLeftPart = false;
    let propsStr = '';
    for (let index = 0; index < propsTemp[0].length; index++) {
      const str = propsTemp[0][index];
      if (str === '{') {
        leftPart.push('{');
        if (!isHasLeftPart) {
          isHasLeftPart = true;
        }
      }
      if (str === '}') {
        leftPart.pop();
      }
      if (isHasLeftPart) {
        propsStr += str;
      }
      if (leftPart.length === 0 && isHasLeftPart) {
        break;
      }
    }
    // 去掉props中的注释内容
    // propsStr = propsStr.replace(/\s*\/\/(.*?)(?:\r\n|\n|$)/g, '');
    // 对props中的key进行处理
    propsStr = propsStr
      .replace(/default:/g, '"default":') // 对js的保留字进行处理
      .replace(/[\f\n\r\t\v]/g, '') // 去掉换行符等无用字符
      .replace(/[\s]{2,}/g, ' ') // 把多个空格换成两个空格
      .replace(/, }/g, () => '}') // 去掉最后一个对象属性的逗号,
      // 对type的值,加上双引号。
      .replace(
        /:\s*(String|Number|Object|Array|Function|Boolean){1}/g,
        (k) => `:"${k.substring(1).replace(/^\s*/g, '')}"`,
      );
    // eslint-disable-next-line no-new-func
    const props = Function(`"use strict";return (${propsStr})`)();
    return props;
  }
  return null;
};

附录: node调试vue工程,在vscode中的配置文件如下,各位有兴趣也可以去试一下:

{
  // 使用 IntelliSense 了解相关属性。 
  // 悬停以查看现有属性的描述。
  // 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387
  "version": "0.2.0",
  "configurations": [
    {
      "type": "pwa-node",
      "request": "launch",
      "name": "Launch Program",
      "skipFiles": [
        "<node_internals>/**"
      ],
      "program": "${workspaceFolder}/wg_component_test/node_modules/.bin/vue-cli-service",
      "args": ["build"],
      "cwd": "${workspaceFolder}"
    }
  ]
}