vue2 组件库开发记录-搭建环境(第二次架构升级)

Posted 在厕所喝茶

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了vue2 组件库开发记录-搭建环境(第二次架构升级)相关的知识,希望对你有一定的参考价值。

前言

本文主要是记录我在开发组件库时如何搭建环境(第二次架构升级)。

项目架构变化

本次架构升级主要参考了element-plus,包含以下几方面:

  • 打包构建工具由webpack改成rolluprollup配置要比webpack简单的多了。rollup天然支持tree sharking,并且可以打包出es module格式的文件,这样我们的 js 代码就可以天然支持es moduletree sharking功能
  • 单包架构改成多包架构。使用lerna进行项目管理。每个组件是一个子项目,这样每个组件就可以单独发布和使用,天然按需加载。
  • 样式单独打包。在之前,样式跟组件是一起进行打包的,然后借助mini-css-extract-plugin将样式抽离出来了。但是这种方式并不适合多包架构,所以需要使用gulp对样式进行单独打包
  • 组件和公共代码的引用。在之前,组件跟公共代码是通过webpack提供的路径别名进行引用的,相对于相对路径,可以少写很多的../,并且我们在打包一个组件的时候是不需要吧公共代码和其他组件的代码也打包进去,这样会造成代码的冗余。所以借助路径别名的另一个好处就是可以精准匹配我们引用的公共代码和组件,然后借助externals字段排除掉这些依赖文件。升级到多包架构之后,公共代码和组件都是一个 npm 包,所以我们是通过依赖包的形式引入公共代码和组件的,在使用rollup的时候直接读取package.json中的dependencies字段,通过external字段忽略这些依赖包的打包。相比这下,使用 npm 包引入会比路径别名引入简单多了。

初始化 lerna

  • 安装 lerna
npm i lerna -g
  • 初始化

首先在项目的根目录初始化package.json

npm iniy -y

然后再初始化lerna,我们才用的是固定模式来管理每个包

lerna init
  • 配置 lerna

这个时候你会看见一个lerna.json文件。在文件中写入一下配置:

{
    // 子项目所在的目录
  "packages": [
    "packages/*"
  ],
//   版本号
  "version": "1.3.4",
  "command": {
    //   lerna publish命令相关配置
    "publish": {
        // 忽略md文件的修改。如果某个子项目只修改了md文件,是不会被publish的
      "ignoreChanges": [
        "*.md"
      ],
    //   lerna publish 之后lerna会自动给我们提交代码,message就是提交代码的信息
      "message": "chore(release): publish",
    //   指定发布的地址,私服的话就填写私服的地址
      "registry": "https://registry.npmjs.org"
    }
  }
}

  • 依赖提升

当我们安装依赖的时候,node_modules文件夹会出现在每一个子项目中,我们需要将他们提取到顶层目录中,避免相同依赖包安装多次。所以我们还需要修改一下我们的配置。

lerna.json文件中加入如下配置:

{
    // 使用yarn安装依赖,没有安装yarn的需要安装一下
  "npmClient": "yarn",
//   工作目录
  "useWorkspaces": true,
}

package.json文件中加入如下字段:

{
    // 声明为私包
  "private": true,
//   工作目录
  "workspaces": ["packages/*"]
}
  • lerna 常用命令

经过上面的步骤,我们的 lerna 基本环境已经出来了,下面介绍几个常见的命令

安装所有子项目的依赖包:

lerna bootstrap

给每个项目添加依赖:

lerna add lodash

给指定的子项目添加依赖:

lerna add lodash --scope=@lin-view-ui/button

@lin-view-ui/button指的是子项目中package.json文件中的name字段,并不是指文件夹

显示自上次 publish 之后,有修改过的子项目:

lerna changed

新建一个子项目,但是组件库这里基本用不上,因为生成出来的文件没有符合我的要求,所以需要使用 node 编写一个脚本,代替这条命令:

lerna add button

发布项目:

lerna publish

在发布之前,会让你挑选版本号。发布之后会自动提交你的代码,然后打tag

项目目录结构

- project
  - build  // 打包构建
  - packages
    - button        // button组件
    - alert         // alert组件
    - types         // 类型声明文件
    - utils         // 公共方法
    - locale        // i18n
    - mixins        // 公共mixins
    - test-utils    // 测试相关的方法
    - theme-chalk   // 样式
    - lin-view-ui   // 组件库总入口,全量包
  - script // 脚本命令

子项目目录结构

这里我们的子项目分为 4 中类型,分别是组件,类型声明文件,样式,公共代码。

  • 组件:包含__test__(编写测试用例的文件夹),src(编写代码的地方),types(类型声明文件),index.js(打包的入口文件),package.jsonREADME.md项目说明
  • 类型声明文件:包含src(编写类型声明文件),index.d.ts(入口),package.jsonREADME.md项目说明
  • 公共代码:我们的公共代码分为 3 个子项目,分别是@lin-view-ui/utils(工具类),@lin-view-ui/test-utils(测试相关工具类),@lin-view-ui/mixins(公共 mixins),@lin-view-ui/locale(i18n 相关的东西),每个子项目包含src(编写代码的地方),index.js(打包的入口文件),package.jsonREADME.md项目说明
  • 样式:src(编写样式的地方),package.jsonREADME.md项目说明

特别说明:

  • 因为项目根目录的package.jsonprivate字段为true,所以每个子项目中的package.json文件需要添加如下字段,否则会导致发包不成功
{
  "publishConfig": {
    "access": "public"
  }
}
  • 组件子项目package.json文件必须包含如下字段:
{
  "typings": "types/index.d.ts",
  "peerDependencies": {
    "vue": "^2.6.14"
  },
  "dependencies": {
    "@lin-view-ui/types": "^1.3.3"
  }
}

由于我们的组件是依赖于 vue,所以需要预安装 vue。同时我们还需要类型声明文件来支持 ts。类型声明文件统一写在@lin-view-ui/types子项目中。组件子项目依赖于这个子项目,然后在types/index.d.ts文件中引入对应的组件类型声明,然后在导出。

  • 每个子项目package.json文件包含的如下字段:
{
    // 包名
  "name": "@lin-view-ui/tag",
//   版本号
  "version": "1.3.4",
//   描述
  "description": "tag",
//   npm包入口文件
  "main": "dist/index.js",
//   关键词
  "keywords": [
    "lin-view-ui",
    "tag"
  ],
//   首页地址
  "homepage": "https://github.com/c10342/lin-view-ui/tree/master/packages/tag",
//   仓库地址
  "repository": {
    "type": "git",
    "url": "https://github.com/c10342/lin-view-ui"
  },
//   issues地址
  "bugs": {
    "url": "https://github.com/c10342/lin-view-ui/issues"
  },
//   作者
  "author": "c10342",
//   开源协议
  "license": "MIT",
}

使用脚本创建组件子项目的模板

由于一个组件子项目包含了多个文件模板,测试文件模板,readme 文件模板,index.js 文件模板等等,这些如果是你自己手动去创建的话会很麻烦,所以我们借助 node 编写一个脚本。

  • 使用方式

node ./scripts/componentTemplate.js button

  • 获取创建的组件名
const argv = process.argv;

const componentName = argv[2]; // button
  • 检查是否输入组件名,或者组件名是否已经存在了
const path = require("path");
const packageRoot = path.resolve(__dirname, "../packages");
// 检查有没有输入组件名
if (!componentName) {
  console.log(chalk.blueBright("请输入组件名"));
  return;
}
const compomentPath = path.resolve(packageRoot, componentName);
// 检查输入的组件名是否已经存在了
if (fs.existsSync(compomentPath)) {
  console.log(chalk.blueBright(`${componentName}组件已经存在了`));
  return;
}
  • 创建根目录,src、types 等目录
// 创建组件根目录
fs.mkdirSync(compomentPath);
// 创建src目录
const compomentSrcPath = path.resolve(compomentPath, "src");
fs.mkdirSync(compomentSrcPath);
// 创建__tests__目录
const testsSrcPath = path.resolve(compomentPath, "__tests__");
fs.mkdirSync(testsSrcPath);
// 创建types目录
const typesSrcPath = path.resolve(compomentPath, "types");
fs.mkdirSync(typesSrcPath);
  • 创建模板文件

下面以创建一个package.json文件为例,其他文件模板创建是同样的道理。思路:读取模板->借助handlebars填写模板数据->写入最终模板字符串到文件目录中

function writePakcageTpl() {
  const parmas = {
    name: componentName,
  };
  const tplStr = fs.readFileSync(
    path.resolve(__dirname, "./template/package.tpl"),
    "utf-8"
  );
  const result = handlebars.compile(tplStr)(parmas);
  fs.writeFileSync(path.resolve(compomentPath, "package.json"), result);
}
writePakcageTpl();

编写 rollup+scss 的配置

  • 读取项目依赖

读取每个子项目中的package.json文件的dependenciespeerDependenciesdevDependencies,这些都是要在打包的时候排除的掉的依赖。

const root = path.resolve(__dirname, "../packages");
function getExternalsDep(name, dev = false) {
  // 获取子项目根目录
  const dir = path.resolve(root, name);
  // 加载package.json
  const pck = require(path.resolve(dir, "./package.json"));
  const dependencies = pck.dependencies || {};
  const peerDependencies = pck.peerDependencies || {};
  const externals = [];
  // 获取key值
  Object.keys(dependencies).forEach((key) => externals.push(key));
  Object.keys(peerDependencies).forEach((key) => externals.push(key));
  if (dev) {
    const devDependencies = pck.devDependencies || {};
    Object.keys(devDependencies).forEach((key) => externals.push(key));
  }
  return [...new Set(externals), "flv.js/dist/flv.js"];
}
getExternalsDep("button");
  • 创建输入配置

安装依赖

npm i @rollup/plugin-node-resolve rollup-plugin-babel@4.4.0 @rollup/plugin-commonjs rollup del rollup-plugin-terser @rollup/plugin-image rollup-plugin-vue@5.1.9 vue-template-compiler -D

特别注意:rollup-plugin-vue 最新版已经出到6.0.0了,但是6.0.0这个版本不支持vue2的打包,所以我们要回退到5.x的版本。rollup-plugin-babel最新版出到了5.x了,但是我发现 vue 组件内部的箭头函数没有被转换,不知道为什么,回退到4.x的版本箭头函数就可以被转换了

const { nodeResolve } = require("@rollup/plugin-node-resolve");
const babel = require("rollup-plugin-babel");
const commonjs = require("@rollup/plugin-commonjs");
const vue = require("rollup-plugin-vue");
const { terser } = require("rollup-plugin-terser");
const image = require("@rollup/plugin-image");
function createInputConfig(options = {}) {
  const config = {
    // 入口文件
    input: options.input,
    // 不需要进行打包的文件或者依赖
    external: options.external,
    plugins: [
      // 识别node_modules目录
      nodeResolve(),
      // 处理vue文件
      vue({}),
      babel({
        // 防止打包node_modules下的文件
        exclude: "node_modules/**",
        // 使plugin-transform-runtime生效
        runtimeHelpers: true,
      }),
      // 将 CommonJS 模块转换为 ES6 的 Rollup 插件,rollup默认只支持 ES6+的模块方式
      commonjs(),
      //   将图片转成base64
      image(),
    ],
  };
  //  压缩文件
  if (options.minify) {
    config.plugins.push(terser());
  }
  //   其他插件
  if (options.plugins) {
    config.plugins.unshift(...options.plugins);
  }
  return config;
}

注意:plugins需要注意一下插件的顺序,特别是@rollup/plugin-commonjs这个插件的顺序。

由于我们的配置中使用babel所以,我们需要在项目的根目录中新建一个babel.config.js文件,配置一下 babel 的一些东西

module.exports = {
  presets: [
    [
      // 配置支持es的一些新特性
      "@babel/preset-env",
      {
        //   按需引入需要用到的一些新特性语法
        useBuiltIns: "usage",
        // 使用core-js3的版本
        corejs: 3,
        targets: {
          // 打包出来的文件最低需要支持到ie10
          ie: "10",
        },
      },
    ],
  ],
  plugins: [
    //   避免全局变量污染
    "@babel/plugin-transform-runtime",
    // 处理vue的jsx语法
    "@vue/babel-plugin-transform-vue-jsx",
  ],
};
  • 创建输出配置
/**
 * @param {*} distPath 打包的输出路径
 * @param {*} options 其他可选配置参数
 */
function createEsOutput(distPath, options = {}) {
  return {
    file: distPath,
    format: "es",
    ...options,
  };
}
// 打包出umd格式的文件
function createUmdOutput(distPath, options = {}) {
  return {
    file: distPath,
    format: "umd",
    ...options,
  };
}
  • 打包构建 js 和 vue 文件

安装依赖

npm i rollup -D
const rollup = require("rollup");
/**
 * @param {*} inputOptions 输入配置 createInputConfig创建
 * @param {*} outputOptions 输出配置 createEsOutput或者createUmdOutput创建
 */
async function rollupBuild(inputOptions, outputOptions) {
  const bundle = await rollup.rollup(inputOptions);
  await bundle.write(outputOptions);
}
  • 打包 scss

安装依赖

npm i gulp gulp-sass gulp-clean-css gulp-rename gulp-autoprefixer gulp-concat node-sass sass -D

打包 css 包含 2 部分,一是将scss编译为css,二是拷贝字体图标文件,因为 gulp 是不会去处理字体图标文件,所以需要拷贝一份到输出目录

const { src, dest } = require("gulp");
// 编译scss
const sass = require("gulp-sass");
// 压缩样式文件
const cssmin = require("gulp-clean-css");
// 重命名文件名
const rename = require("gulp-rename");
// 样式添加前缀
const autoprefixer = require("gulp-autoprefixer");
// 将多个文件拼接成一个文件
const concat = require("gulp-concat");
const path = require("path");
// 样式存放的目录
const root = path.resolve(__dirname, "../packages/theme-chalk");
const resolve = (pathSrc) => path.resolve(root, pathSrc);
// 编译scss。srcPath:入口,可以是数组,最终会合并成一个文件;distPath:输出路径
const buildScss = (
  srcPath,
  distPath,
  options = {
    basename: "style",
  }
) => {
  return (
    src(srcPath)
      //   将多个样式文件拼接成一个
      .pipe(concat("style.scss"))
      // 编译scss
      .pipe(sass().on("error", sass.logError))
      // 给样式添加前缀
      .pipe(autoprefixer({ cascade: false }))
      // 压缩样式文件
      .pipe(cssmin())
      .pipe(
        // 修改文件名
        rename((srcPath) => {
          srcPath.basename = options.basename;
          // 后缀名
          srcPath.extname = ".css";
        })
      )
      // 输出到指定目录
      .pipe(dest(distPath))
  );
};
// 将字体图标文件拷贝到输出目录
function copyfont(distPath) {
  return src(resolve("./src/fonts") + "/**")
    .pipe(cssmin())
    .pipe(dest(distPath));
}
  • 其他

删除指定目录

const del = require("del");
const clean = (cleanPath) => {
  return del(cleanPath, {
    force: true,
  });
};

非组件子项目的子项目(白名单)

const whiteList = [
  "locale",
  "mixins",
  "theme-chalk",
  "utils",
  "lin-view-ui",
  "test-utils",
  "types",
];

打包公共代码

我们的公共代码主要有 4 个子项目,其中有三个需要进行打包的,分别是utilslocalemixins。下面以讲解打包utils为例,其他 2 个子项目跟他是一样的

// 子项目根路径
const root = path.resolve(__dirname, "../packages/utils");
const resolve = (pathSrc) => {
  return path.resolve(root, pathSrc);
};
// 创建输入配置
function createConfig(filename) {
  let input = filename;
  // 如果文件名不是index.js,路径需要加一个src
  if (filename !== "index.js") {
    input = `./src/${filename}`;
  }
  return createInputConfig({
    input: resolve(input),
    external: getExternalsDep("utils"),
  });
}
// 打包单个文件的函数
const buildOne = async (filename) => {
  const inputOptions = createConfig(filename);
  const outputOptions = createEsOutput(resolve(`./dist/${filename}`));
  await rollupBuildwebpack+vue2.0+nodeJs搭建环境

饿了么基于Vue2.0的通用组件开发之路(分享会记录)

Vue2事件总线在第二次调用后工作

Vue2事件总线在第二次调用后工作

Vue2.0 新手入门 — 从环境搭建到发布

微信小程序组件库开发记录