按需导入之vue组件根据标签导入依赖组件

joyerli
本文提供一个简单的在vue单文件组件中根据标签自动实现组件注册的构建插件

在开发vue项目时, 组件注册有全局注册和局部注册之分. 全局注册后的组件在每一个其他组件中都可以使用, 无需再次导入注册, 具有良好的开发体验. 但也会导致首屏包过大, 降低用户体验.

可以不可以有一种方式, 让开发人员开发时像全局注册一样, 实际打包又跟局部注册一样支持按需导入呢?

本文提供一种实现的思路:

  • vue文件在加载时, 解析模板, 记录所有的使用的html标签.
  • 在babel插件进行代码转换时, 将html标签对应的组件注入到当前组件中.
这是笔者所在团队的实现方式, 末开源, 所以文档记录一下.

如果你有更好的思路, 可以留言交流.

记录vue文件的html标签

在vue-loader在初次解析vue文件后, 会将原文件拆分成多个部分(一般三个部分: 模板, 样式, 脚本), 然后再次调用vue-loader解析. 实现当前loader需要在vue-loader初次解析之后,模板编译之前.
又因为vue-loader中的webpack插件会将用于vue模板编译的处理loader强制置于最前位, 所以只需将实现的loader放在vue-loader之前即可.

在loader中, 首先要判断当前的资源类型, 也就是只处理经过初次vue-loader拆分出来的理模板部分:

if (!context.resourceQuery.includes('type=template')) {
    return source;
}
context为loader上下文, 也就是loader函数中的this.
然后对模板代码进行解析, 这里使用库node-html-parser进ast解析:
const root = nodeHtmlParser.parse(code, {
  script: true,
});

function ast2Tag(nodes) {
  return nodes.reduce((_list, node) => {
    let list = _list;
    node.tagName && list.push(node.tagName);
    if (node.childNodes) {
      list = list.concat(ast2Tag(node.childNodes));
    }
    return list;
  }, []);
}

const tags = [...new Set(ast2Tag([root]))];

然后保存下来, 这里采用保存到硬盘来跟后续的babel插件通信.

高版本webpack采用多进程构建, 所以不使用内存通信.
const Cache = require('sync-disk-cache');
const NS = '__upman-record-tags';

const cache = new Cache(NS, {
  location: resolve('node_modules/.cache/sync-disk-cache'),
});
cache.set(context.resourcePath, JSON.stringify(tags));
resolve函数是获取相对于当前项目目录(不是库项目目录)相对路径的绝对路径, 你放在其他指定的缓存目录也是可以的.

babel插件中自动注入组件.

为了降低复杂性, 这里只要针对使用配置对象的vue单文件组件进行自动注入进行说明, 也就是类似下面代码:

<style></style>
<template><div></div></template>
<script>
export default {
  components: {
  },
  data() {
    return {
    };
  },
  created() {
  },
};
</script>

当前自动注入组件的babel插件支持选项:

  • lib(tag: string, filePath: string): string: 获取组件模块导入路径, 如果返回空白值代表忽略当前标签
  • style(tag: string, filePath: string): string: 获取组件资源文件的导入路径, 如果返回空白值代表忽略当前标签

在入口节点中, 获取当前文件对应的标签列表:

Program(root, state) {
  if (isExcludeFile(state)) {
    return;
  }
  tags = getTags(state.filename) || [];
},

然后在export default语句中完成剩下的逻辑, 也就是表达式ExportDefaultDeclaration的访问器中:

ExportDefaultDeclaration(path, state) {
    // 后续逻辑
}

tag进行转换:

const {
  lib = () => false,
  style = () => false,
} = state.opts || {};
const {
  filename: filePath,
} = state;
let components = tags.filter((tag) => tag);.map((tag) => ({
  lib: lib(tag, filePath),
  style: style(tag, filePath),
  tag,
}))
  .filter((component) => component.lib || component.style)

经过上面的转换, 所有的标签都转为对应组件要导入模块或者资源的路径.

通过定制组件的资源路径, 就可以实现按需导入

然后导入组件对应的模块和样式文件:

components.map((component) => {
  if (component.lib) {
    const { name: importName } = addDefault(path, component.lib);
    component.code = render(`{
      tag: '{{tag}}',
      component: {{componentName}},
    }`, {
      tag: component.tag,
      componentName: importName,
    });
  }
  if (component.style) {
    addSideEffect(path, component.style);
  }
  return component;
});

上面的代码中, 还对组件列表components维护了code值, 内容是注册组件的代码.

这里说明一下后续的思路:
先把原有的配置组件拆分成变量引用:

export default { ... };

转换为:

const compoentConfig = { ... };
export default compoentConfig;

然后获取原有的组件注册集:

const originComponents = compoentConfig.components || {};

然后将分析出来的组件的注册代码合并到原有注册代码, 这里伪代码示意:

merge(pluginComponents,originComponents);

最后覆盖:

compoentConfig.components = originComponents;

参照上面的思路, 对原有语句进行拆分:

const exportVarNode = path.scope.generateUidIdentifier('component');
path.insertBefore(t.variableDeclaration('const', [
  t.variableDeclarator(exportVarNode, path.node.declaration),
]));
path.node.declaration = exportVarNode;

然后获取原有注册集并合并, 由于合并处理的代码稍微有点复杂,封装成一个运行时函数, 然后导入使用(作为运行时代码):

const { name: registerName } = addDefault(path, registerPath);

然后将插件分析出来的组件注册代码合并成一个数组:

const componentsCode = `[${components.map((component) => component.code).join(',')}]`;

利用上面预处理的数据, 完成获取原有注册集和合并:

const buildExtendComponents = template(`
  const components = %%exportName%%.components || {};
  ${registerName}(${componentsCode}, components);
  %%exportName%%.components = components;
`);
const componentsNode = buildExtendComponents({
  exportName: exportVarNode,
});
path.insertBefore(componentsNode);

合并注册组件集的函数的代码为(运行时代码直接跑在浏览器, 所以需要用es5编写):

function kebabCase(str) {
  var hyphenateRE = /([^-])([A-Z])/g;
  return str
    .replace(hyphenateRE, '$1-$2')
    .replace(hyphenateRE, '$1-$2')
    .toLowerCase();
}

export default function (options, components) {
  var componentNames = [];
  for (var componentsKey in components) {
    componentNames.push(kebabCase(componentsKey));
  }
  options.forEach(function (option) {
    var tag = kebabCase(option.tag);
    var has = false;
    for(var i = 0; i < componentNames.length; i++) {
      if (has) {
        continue;
      }
      var componentName = componentNames[i];
      has = componentName === tag;
    }
    if (!has) {
      components[tag] = option.component;
    }
  });
};

自此, 一个简单的vue单文件自动注入组件的工具就已经实现了.

写在最后

上文中只针对一种声明vue组件的方式进行了开发, 适用范围较窄.

对于官方推荐的其他声明组件的方式(为了支持ts), 如使用extend方式的组件:

import Vue from 'vue';
const Component = Vue.extend({
});
export default Component;

或者使用类声明组件:

import Vue from 'vue'
import Component from 'vue-class-component'

@Component({
})
export default class MyComponent extends Vue {
}

可以自己参照实现,

阅读 203

前端搬砖一枚

80 声望
1 粉丝
0 条评论

前端搬砖一枚

80 声望
1 粉丝
宣传栏