整合营销服务商

电脑端+手机端+微信端=数据同步管理

免费咨询热线:

如何搭建组件库的最小原型

如何搭建组件库的最小原型

ear,大家好,我是“前端小鑫同学”,长期从事前端开发,安卓开发,热衷技术,在编程路上越走越远~


写作背景:

???? 现在其实做得不错的开源 UI 库有很多,我还没有真正的实践过多造一个轮子也没太大必要,但是学习编写的思路和过程还是很有必要的,正好看到慕课的一个视频就顺便总结一下组件库开发的流程,顺便熟悉一个打包的配置和流程。

搭建基础结构:

使用VueCli创建默认模板:

  1. 创建名为it200-ui的项目:vue create it200-ui
  2. 使用默认Vue2模板即可,我们只考虑搭建UI库的思路不考虑版本的选择;
  3. 按提示命令进入项目cd it200-ui,并启动yarn serve

调整目录使适合UI库开发:

  1. 调整src/components层级到根目录;
  2. 调整src为组件渲染示例examples
  3. 通过在 vue.config.js 配置pages节点来更改入口;

创建第一个演示组件:

???? 目录结构如下,需按要求安装开发依赖sass-loader,为了避免与 node-sass 的版本冲突造成的更多问题,我们不再安装它而去添加一个名为sass包;

components
├─lib
|  ├─demo
|  |  └index.vue
├─css
|  └demo.scss

在示例文件夹的 main.js 中导入并申明组件:

import "../components/css/demo.scss";
import Demo from "../components/lib/demo/index.vue";
Vue.component("name", Demo);

创建组件安装脚本:

???? 通常在使用开源 UI 库时并没有使用 component 不是导入组件,而是使用的 use 进行安装,所以我们在组件的同目录创建一个组件的安装脚本:

import Demo from "./index.vue";

Demo.install=function (Vue) {
  Vue.component(Demo.name, Demo);
};

export default Demo;

使用组件安装脚本注册组件:

import Demo from "../components/lib/demo/index.js";
Vue.use(Demo);

开发一个组件的生命周期:

设计组件:

???? 组件的设计一定是为了满足多处的复用而提出来的,站在各自的角度也可能都会有不一样的答案,所以我们这里找了 Elementcard 组件中的一块内容来充当我们今天待设计组件的需求:

组件设计稿:

卡片组件需要满足以下几点要求,其他的要求暂不考虑:

  1. 支持通过 body-style 属性来覆盖默认的 body 区域属性;
  2. 支持通过 shadow 属性来设置阴影出现的时机;

image.png

组件提供的属性:

参数 说明 类型 可选值 默认值 body-style 设置 body 的样式 object — { padding: '20px' } shadow 设置阴影显示时机 string always / hover / never always

编写组件模板:

创建card 组件的结构:

components/
├─lib
|  ├─card
|  |  ├─index.js
|  |  └index.vue
├─css
|  └card.scss

注册并在 App.vue 中使用组件:

import "../components/css/card.scss";
import Card from "../components/lib/card/index.js";
Vue.use(Card);

按设计要求为组件添加属性:

通过 props 提供组件的上述基础属性。

export default {
  name: "it-card",
  props: {
    bodyStyle: {
      type: Object,
      default: ()=> {
        return { padding: "20px" };
      },
    },
    shadow: {
      type: String,
      default: "always",
    },
  },
};

编写组件模板的框架:

???? 组件的大致结构如下,通过三层 div 来设置卡片组件容器、阴影、内容区的样式,并提供默认插槽来设置具体内容。

<template>
  <div class="it-card">
    <div :class="`is-${shadow}-shadow`"></div>
    <div class="it-card__body" :style="bodyStyle">
      <slot></slot>
    </div>
  </div>
</template>

编写组件的样式:

.it-card {
  border-radius: 4px;
  border: 1px solid #ebeef5;
  background-color: #fff;
  overflow: hidden;
  color: #303133;
  transition: 0.3s;
  .it-card__body {
    padding: 20px;
  }
  .is-always-shadow {
    box-shadow: 0 2px 12px 0 rgb(0 0 0 / 10%);
  }
  .is-hover-shadow:hover {
    box-shadow: 0 2px 12px 0 rgb(0 0 0 / 10%);
  }
  .is-never-shadow {
    box-shadow: none;
  }
}

在 App.vue 中完善卡片组件:

在 app.vue 中完善卡片组件,并对比组件设计稿。

<template>
  <div id="app">
    <h3>Card组件</h3>
    <it-card style="width: 300px" :body-style="{ padding: '0px' }">
      <img
        src="https://shadow.elemecdn.com/app/element/hamburger.9cf7b091-55e9-11e9-a976-7f4d0b07eef6.png"
        class="image"
      />
      <div style="padding: 14px">
        <span>好吃的汉堡</span>
        <div class="bottom">
          <time class="time">"2022-05-03T16:21:26.010Z"</time>
        </div>
      </div>
    </it-card>
  </div>
</template>

<script>
export default {
  name: "App",
};
</script>

<style>
// 这里的样式使用 element card 使用的样式
</style>

构建 UMD 模块:

在前端模块化的进程中,经过了全局函数、命名空间,匿名函数自调,文件模块化方案,尤为常见的文件模块化方案就是 CommonJs,ADM,UMD 了,下面来介绍一下各自的特点;

CommonJs:

  1. 文件作用域:每个文件即为一个单独的模块,模块中的内容未主动暴露则对外不可见;
  2. 缓存:模块的加载只发生在第一次导入,在之后的导入会优先读取缓存;
  3. 同步加载:同步加载能保证在使用时必定存在该模块,但是并不适用于浏览器端,当同步加载慢的时候可能造成浏览器假死的状态发生。

结论:CommonJs 的模块更适用于服务端应用。

AMD:

  1. 文件作用域:同 CommonJs,也是模块化的主要产物;
  2. 异步加载:异步加载更好地适用于浏览器端,可以在异步加载后通过回调来执行后续的脚本。

结论:AMD 的模块更适用于浏览器端应用。

UMD通用模块:

  1. 同时满足适用于浏览器和服务端的模块化解决方案;
  2. 通过判断是否包含毒素 exports 来确认是否支持 Node.js 模块;
  3. 通过判断是否包含 define 来确认是否支持 AMD 模块;
  4. 上述两个特点均不存在则将模块挂载到全局(windowglobal)。

使用 Webpack 来打包组件逻辑代码:

定义 webpack 打包配置文件webpack.components.js:

  1. 组件的打包我们使用多入口的方式分别处理,所以我们首先处理入口,通过遍历组件 lib 目录来得到一个以组件名和组件路径组成的键值对。
const glob=require("glob");

let entrys={};

async function makrList(dirPath, list) {
  const files=glob.sync(`${dirPath}/**/index.js`);
  for (let file of files) {
    const component=file.split(/[/.]/)[2];
    list[component]=`./${file}`;
  }
}

makrList("components/lib", entrys);
  1. 接下来我们处理出口的配置: 输出文件名称:使用入口的 key 来区分各个组件,并使用通过的 umd 作为组件输出产物的标识;输出目录:这里需要注意使用绝对路径来指定输出文件的位置;libraryTarget和library有相互依赖的关系,主要用来指定模块的暴露方式和模块的别名,这一块的描述我觉得 Rollup 中的描述将更清晰。
const path=require("path");

module.exports={
  entry: entrys,
  output: {
    filename: "[name].umd.js",
    path: path.resolve(__dirname, "dist"),
    library: "it200",
    libraryTarget: "umd",
  },
};
  1. 最关键的是我们的 webpack 默认不认识.vue 的文件我们需要使用对应的loader来处理,Vue 文件对应的就是vue-loader,需要注意的是我们目前基于 Vue2 来构建的项目,所以最新的vue-loader并不是特别适合我们可以降级到 **15** 版本来让构建正常进行。
const { VueLoaderPlugin }=require("vue-loader");

module.exports={
  plugins: [new VueLoaderPlugin()],
  module: {
    rules: [
      {
        test: /\.vue$/,
        use: [
          {
            loader: "vue-loader",
          },
        ],
      },
    ],
  },
};
  1. 为了方便调用我们还是配置一下打包命令:
"build:js": "webpack --config ./webpack.components.js"

为了满足全部导入的要求,我们还需要将组件整合:

在 lib 目录下新建一个index.js 文件将我们的组件统一导入后统一执行组件挂载。

import Demo from "./demo";
import Card from "./card";

const components={
  Demo,
  Card,
};

const install=function (Vue) {
  if (install.installed) return;
  Object.keys(components).forEach((key)=> {
    Vue.component(components[key].name, components[key]);
  });
};

export default {
  install,
};

使用Gulp 来打包组件的样式代码:

gulp 主要通过定义任务并使用流式的处理方式使用不同的管道依次进行,我们主要处理 scss 文件内容为 css 文件。

需要用到的模块如下:

  1. gulp-sass,因版本问题需要额外导入 sass 模块。
  2. gulp-minify-css:主要用来对 css 文件进行压缩。

完整的打包配置如下:

  1. 配置文件指明了操作的文件入口为css 目录下的 scss 结尾的文件;
  2. 文件输出到 dist/css 目录下;
  3. 方便执行我们配一下打包命令:"build:css": "npx gulp sass"
const gulp=require("gulp");
const sass=require("gulp-sass")(require("sass"));
const minifyCSS=require("gulp-minify-css");

gulp.task("sass", async function () {
  return gulp
    .src("components/css/**/*.scss")
    .pipe(sass())
    .pipe(minifyCSS())
    .pipe(gulp.dest("dist/css"));
});

将模块化的 scss 文件整合到一起,方便全部加载:

在 css 目录新建 index.scss 文件,并将各个组件需要的 scss 文件导入到此文件。

@import "./card.scss";
@import "./demo.scss";

按需引入和全部引入:

import "../dist/css/index.css";
import IT200UI from "../dist/index.umd";
Vue.use(IT200UI);
import "../dist/css/card.css";
import Card from "../dist/card.umd";
Vue.use(Card);

import "../dist/css/demo.css";
import Demo from "../dist/demo.umd";
Vue.use(Demo);

发布组件库到 NPM:

  1. 注册 npm 用户;
  2. 调整 package.json ;

调整 package:

  1. 移除私有配置:private;
  2. 添加组件库描述信息:description;
  3. 添加组件入口文件:main;
  4. 添加组件相关的关键词:keywords;
  5. 添加作者名字:author;
  6. 添加组件库发布的内容:files;

完成新增内容如下:

{
  "description": "IT200 组件库,最小原型演示",
  "main": "dist/index.umd.js",
  "keywords": [
    "it200",
    "ui",
    "组件库"
  ],
  "author": "fe-xiaoxin",
  "files": [
    "dist",
    "components"
  ]
}

调整组件库的说明文档:

  1. 包含组件库的安装方式;
  2. 包含组件库的引用方式;

快速开始

如何安装

npm i it200-ui

如何引入

// 全部引入
import 'it200-ui/dist/css/index.css';
import IT200UI from 'it200-ui';
Vue.use(IT200UI);

// 按需引入
import 'it200-ui/dist/css/cart.css';
import { Card } from 'it200-ui';
Vue.use(Card);

正式开始发布:

  1. 确认 NPM 源为修改成其他镜像地址,我这里使用 nrm 包进行源的管理,可以通过 nrm ls查询和 nrm use 进行切换;
  2. 执行 npm login 开始登陆,分别输入用户名、密码、邮箱,开通动态验证的话还需要输入动态验证码,开通的方式可以翻我以前的文章;
  3. 执行 npm publish 开始发布,开通动态验证码的话需要再次验证动态验证码;

image.png

写到最后:

???? 整个组件库的开发我们省略了最后一步,因为版本的问题导致 vuepress 没有成功的配置,在开发组件库的过程中使用到的技术栈可以是五花八门但是通过本次总结到的我们开发组件库的生命周期大致统一应该是搭建结构、设计组件、编写组件、验证组件、打包构建、发布为主线,构建组件库文档站点、编写使用手册、自动化构建发布为支线同步进行。

  • 内容设计的源码地址:https://github.com/OSpoon/it200-ui;

码哥深入Webpack5等构建工具(gulp/rollup/vite)|完结无秘

来百度APP畅享高清图片

//下栽のke:chaoxingit.com/580/

深入理解前端构建工具:Webpack 5、Gulp、Rollup 和 Vite

在现代前端开发中,构建工具是不可或缺的一部分,它们能够帮助开发者自动化各种任务,如代码压缩、模块打包、代码转换、静态资源管理等。本文将深入介绍四种常用的前端构建工具:Webpack 5、Gulp、Rollup 和 Vite,探讨它们的特点、用途和适用场景。

Webpack 5

特点:

  1. 模块打包器: Webpack 是一个模块打包工具,能够将前端项目中的各种资源(如 JavaScript、CSS、图片等)视作模块,并将其打包成静态资源文件。
  2. 高度可配置: Webpack 提供了丰富的配置选项,可以灵活定制打包过程,满足各种项目的需求。
  3. 代码分割与懒加载: 支持代码分割,可以将代码拆分为多个 bundle,实现按需加载,提高页面加载速度。
  4. 强大的生态系统: Webpack 拥有庞大的社区和丰富的插件系统,能够满足各种需求,扩展其功能。

适用场景:

  • 适用于复杂的前端项目,需要处理多种类型资源和复杂依赖关系的场景。
  • 适用于需要高度定制化构建流程的项目。

Gulp

特点:

  1. 基于任务的构建工具: Gulp 是一个基于任务的前端构建工具,通过定义一系列任务(Task),实现自动化处理文件的流程。
  2. 流式处理: 使用流式处理文件,可以有效提高处理效率和性能。
  3. 简洁灵活的 API: Gulp 提供简洁易懂的 API,易于上手和使用。
  4. 丰富的插件生态系统: Gulp 拥有大量的插件,可以实现各种功能,如文件复制、压缩、合并等。

适用场景:

  • 适用于简单的任务自动化和文件处理,如文件复制、压缩、合并等。
  • 适用于需要处理大量文件或流式处理文件的项目。

Rollup

特点:

  1. ES6 模块打包器: Rollup 是一个专注于 ES6 模块的打包工具,能够将 ES6 模块打包成适合生产环境使用的文件。
  2. Tree-shaking: Rollup 使用 Tree-shaking 技术,可以剔除未使用的代码,生成更小的包。
  3. 简单易用: Rollup 的配置相对简单,易于上手。
  4. 专注于库的打包: Rollup 更适合用于开发库或组件,专注于模块的打包和优化。

适用场景:

  • 适用于开发库或组件,专注于 ES6 模块的打包和优化。
  • 适用于需要生成轻量级、高性能 JavaScript 包的项目。

Vite

特点:

  1. 基于现代浏览器的开发服务器和构建工具: Vite 是一个基于现代浏览器的开发服务器和构建工具,旨在提供快速的开发体验。
  2. ES 模块原生支持: Vite 使用 ES 模块的原生支持,无需打包即可直接运行。
  3. 热更新: 支持热更新(Hot Module Replacement),能够在开发过程中实时更新修改的内容。
  4. 针对现代浏览器优化: Vite 针对现代浏览器进行优化,构建速度快。

适用场景:

  • 适用于快速原型开发,提供快速的开发环境和即时反馈。
  • 适用于现代浏览器,利用 ES 模块的原生支持进行开发。

这些工具各有特点,根据项目的需求和开发团队的偏好,可以选择合适的工具来进行前端构建和开发。

一深入Webpack5等构建工具(gulp/rollup/vite)的知识和技能

深入理解和掌握Webpack 5以及其他构建工具(如gulp、rollup、vite)的知识和技能是前端开发中非常重要的一部分。以下是一些你可能需要掌握的知识和技能:

Webpack 5:

  1. 模块化概念: 理解模块化开发的概念,以及Webpack是如何处理模块的。
  2. 配置文件: 熟悉Webpack的配置文件,理解常用配置项,能够根据项目需求进行配置。
  3. Loader和Plugin: 了解Loader和Plugin的概念,以及如何使用它们进行资源的加载、转换和优化。
  4. Code Splitting: 理解Webpack中的代码拆分技术,实现按需加载,提高应用性能。
  5. DevServer: 使用Webpack DevServer进行本地开发,熟悉热模块替换(HMR)的使用。
  6. 优化: 学会进行Webpack构建的性能优化,包括代码分割、懒加载、Tree-shaking等技术。
  7. Webpack生态系统: 理解Webpack的生态系统,熟悉一些常用的Webpack插件和工具。

Gulp:

  1. 任务流程: 理解Gulp的基本工作原理,了解任务流程是如何定义和执行的。
  2. Gulp API: 熟悉Gulp的API,能够使用Gulp的方法创建和管理任务。
  3. 插件使用: 学会使用各种Gulp插件,实现文件的复制、压缩、合并等任务。
  4. 文件流: 理解Gulp中的文件流概念,使用流式处理提高任务效率。
  5. 监视文件变化: 使用Gulp的watch功能,实现文件变化时自动执行相应任务。

Rollup:

  1. ES6模块: 理解ES6模块的概念,了解Rollup是如何专注于ES6模块的打包和优化的。
  2. Tree-shaking: 深入了解Tree-shaking技术,学会如何剔除未使用的代码。
  3. 配置文件: 熟悉Rollup的配置文件,了解其配置项,能够根据项目需求进行配置。
  4. 插件系统: 了解Rollup的插件系统,学会使用插件完成一些特定的任务。

Vite:

  1. 原生ES模块支持: 理解Vite如何利用浏览器原生ES模块的支持,实现无需打包即可运行。
  2. 热更新: 了解Vite的热更新机制,实现开发过程中的即时反馈。
  3. 插件开发: 学会开发和使用Vite插件,扩展Vite的功能。
  4. 配置文件: 熟悉Vite的配置文件,了解其配置项,能够根据项目需求进行配置。
  5. 优化: 了解Vite的一些优化策略,如代码分割、按需加载等。

其他通用技能:

  1. 调试工具: 使用浏览器开发者工具进行调试,了解Source Map的生成和使用。
  2. 版本控制: 熟悉Git,能够使用版本控制工具管理代码。
  3. 持续集成: 了解持续集成工具,将构建工具整合到CI/CD流程中。
  4. 性能优化: 学会使用各种工具进行性能分析和优化,提高应用的加载速度和性能。

深入掌握这些知识和技能,将使你能够更高效地构建和维护复杂的前端项目。不同的项目可能使用不同的构建工具,根据项目需求选择合适的工具,并深入学习其使用和优化技巧。

一深入Webpack5等构建工具(gulp/rollup/vite)的职责与挑战

深入理解和使用Webpack 5等构建工具(如gulp、rollup、vite)需要面对一些职责和挑战:

职责:

  1. 资源管理: 负责管理项目中的各种资源,包括JavaScript、CSS、图片等,并在构建过程中进行优化和处理。
  2. 模块打包: 将项目中的各个模块打包成适合浏览器加载的形式,包括处理模块间的依赖关系。
  3. 代码转换: 对不同类型的代码进行转换,如将ES6+代码转换成ES5,将Sass/LESS转换成CSS等。
  4. 性能优化: 通过代码压缩、文件合并、代码拆分等手段提高应用的性能和加载速度。
  5. 开发环境支持: 在开发环境中提供热更新、代码分析等功能,提高开发效率。
  6. 生产环境支持: 在生产环境中提供代码压缩、文件指纹、缓存管理等功能,保证应用的稳定性和性能。

挑战:

  1. 学习曲线: 这些构建工具都有一定的学习曲线,特别是对于初学者来说,需要花费时间来理解其工作原理和配置方式。
  2. 复杂配置: 随着项目的复杂度增加,构建工具的配置也会变得复杂,需要花费时间和精力来进行优化和调试。
  3. 性能问题: 不恰当的配置和使用方式可能会导致构建性能下降,甚至引发一些意想不到的问题,需要进行性能分析和优化。
  4. 插件选择: 在使用过程中需要选择合适的插件来完成特定的任务,需要对各种插件进行评估和选择。
  5. 维护成本: 随着项目的演进和变化,构建工具的配置也需要不断地进行维护和更新,增加了项目的维护成本。
  6. 生态变化: 这些构建工具的生态系统都在不断地变化和发展,需要及时跟进新的特性和最佳实践。
  7. 集成问题: 在一些复杂的项目中,构建工具可能需要和其他工具或框架进行集成,需要处理好各种依赖和兼容性问题。

克服这些挑战需要不断地学习和实践,积累经验和技能,同时也需要与社区和团队保持良好的沟通和合作。

一深入Webpack5等构建工具(gulp/rollup/vite)的未来趋势与总结

未来,Webpack 5、Gulp、Rollup、Vite等构建工具将继续发展,面临一些趋势和变化:

  1. 性能优化: 构建工具的性能优化将成为一个重要的趋势。随着前端项目的复杂性增加,构建工具需要更快的构建速度和更高的效率。优化算法、增强缓存机制以及更好的并行处理将成为关注点。
  2. 模块化开发: 随着模块化开发的普及,构建工具将更多地支持模块化开发的需求。对模块的处理、加载和分割将变得更加智能和高效。
  3. 开发体验: 构建工具将更多地关注开发者的体验,提供更友好的命令行界面、详细的错误提示和更好的文档。热更新、快速重构、源代码映射等功能将进一步改善开发体验。
  4. 模块化加载优化: 随着HTTP/2和HTTP/3的普及,模块化加载优化将成为一个重要的方向。构建工具将更多地支持代码分割、懒加载等技术,以减少页面加载时间和提升用户体验。
  5. 跨平台支持: 构建工具将更多地支持跨平台开发,包括Web、移动端、桌面端等。构建工具的灵活性和可配置性将成为一个重要的考量因素。
  6. 生态整合: 构建工具将更多地与周边生态整合,包括与框架、库、测试工具等的集成。更多的插件和扩展将为开发者提供更多的选择和灵活性。

总的来说,未来构建工具将继续朝着更快、更智能、更灵活的方向发展,以适应日益复杂的前端开发需求。开发者需要不断学习和掌握最新的技术和工具,保持对前端技术发展的敏感度和理解能力。

者:glendonli,腾讯 PCG 前端开发工程师

对于大型前端项目而言,构建的稳定性和易用性至关重要,腾讯文档在迭代过程中,复杂的项目结构和编译带来的问题日益增多,极大的增加了新人上手与日常搬砖的开销。恰逢 Webpack5 上线,不如来一次彻底的魔改。

1.前言

腾讯文档最近基于刚刚发布的 Webpack5 进行了一次编译的大重构,作为一个多个仓库共同构成的大型项目,任意品类的代码量都超过百万。对于腾讯文档这样一个快速迭代,高度依赖自动化流水线,常年并行多个大型需求和无数小需求的项目来说,稳定且快速的编译对于开发效率至关重要。这篇文章,就是笔者最近进行重构,成功将日常开发优化到 1s 的过程中,遇到的一些大型项目特有的问题和思考,希望能给大家在前端项目构建的优化中带来一些参考和启发。

2.大型项目编译之痛

随着项目体系的逐渐扩大,往往会遇到旧的编译配置无法支持新特性,由于各种 config 文件自带的阅读 debuff,以及累累的技术债,大家总会趋于不去修改旧配置,而是试图新增一些配置在外围对编译系统进行修正。也是这样类似的原因,腾讯文档过去的编译编译也并不优雅:

多级的子仓库结构,复杂的编译系统造成很高的理解和改动成本,也带来了较高的编译耗时,对于整个团队的开发效率有着不小的影响。

3.All in One

为了解决编译复杂和缓慢的问题,至关重要的,就是禁止套娃:多层级混合的系统必须废除,统一的编译才是王道。在所有编译系统中,Webpack 在大项目的打包上具备很强优势,插件系统最为丰满,并且 Webpack5 带来了 Module Federation 新特性,因此笔者选择了用 Webpack 来统合多个子仓库的编译。

3.1.整合基于 lerna 的仓库结构

腾讯文档使用了 lerna 来管理仓库中的子包,使用 lerna 的好处此处就不作展开了。不过 lerna 的通用用法也带来了一定的问题,lerna 将一个仓库变成了结构上的多个仓库,如果按照默认的使用方式,每个仓库都会有自己的编译配置,单个项目的编译变成了多个项目的联编联调,修改配置和增量优化都会变得比较困难。

虽然使用 lerna 的目的是使各个子包相对独立,但是在整个项目的编译调试中,往往需要的是所有包的集合,那么,笔者就可以忽略掉这个子包间的物理隔离,把子仓库作为子目录来看待。不依赖 lerna,笔者需要解决的,是子包间的引用问题:

/** package/layout/src/xxx.ts **/
import { Stream } from '@core/model';
// do something

事实上,笔者可以通过 webpack 配置中 resolve 的 alias 属性来达到相应效果:

{
  resolve: {
    alias: {
      '@core/model': 'word/package/model/src/',
    }
  }
}

3.2.管理游离于打包系统之外的文件

在大型项目中,有时会存在一些特殊的静态代码文件,它们往往并不参与到打包系统中,而是由其他方式直接引入 html,或者合并到最终的结果中。

这样的文件,一般分为如下几类:

  1. 加载时机较早的外部 sdk 文件,本身以 minify 文件提供
  2. 外部文件依赖的其他框架文件,比如 jquery
  3. 一些 polyfill
  4. 一些特殊的必须早期运行的独立逻辑,比如初始化 sdk 等

由于 polyfill 和外部 sdk 往往直接通过挂全局变量运行的模式,项目中往往会通过直接写入 html script 标签的方式引用它们。不过,随着此类文件的增多,直接利用标签引用,对于版本管理和编译流程都不友好,它们对应的一些初始化逻辑,也无法添加到打包流程中来。这种情况,笔者建议手工的创建一个 js 入口文件,对以上文件进行引用,并作为 webpack 的一个入口。如此,就能通过代码的方式,将这些散装文件管理起来了:

import 'jquery';

import 'raven.min.js';
import 'log.js';
// ...

但是,一些外部的 js 可能依赖于其他 sdk,比如 jQuery,但是打包系统并不知道它们之间的依赖关系,导致 jQuery 没有及时暴露到全局中,该怎么办呢?事实上,webpack 提供了很灵活的方案来处理这些问题,比如,笔者可以通过 expose-loader,将 jQuery 的暴露到全局,供第三方引用。在腾讯文档中,还包含了一些对远程 cdn 的 sdk 组件,这些 sdk 也需要引用一些库,比如 jQuery 的。因此,笔者还通过 splitChunks 的配置,将 jQuery 重新分离出来,放在了较早的加载时机,保证基于 cdn 加载的 sdk 亦能正常初始化。

通过代码引用,一方面,可以很好地进行依赖文件的版本管理;另一方面,由于对应文件的编译也加入了打包流程,所有对应文件的改动都可以被动态监视到,有利于后续进行增量编译。同时,由于 webpack 的封装特点,每个库都会被包含在一个 webpack_require 的特殊函数之中,全局变量的暴露数量也变得较为可控。

3.3.定制化的 webpack 流程

Webpack 提供了一个非常灵活的 html-webpack-plugin 来进行 html 生成,它支持模板和一众的专属插件,但是,仍然架不住项目有一些特殊的需求,通用的插件配置要么无法满足这些需求,要么适配的结果就十分难懂。这也是腾讯文档在最初使用了 gulp 来生成 html 的原因,在 gulp 配置中,有很多自定义流程来满足腾讯文档的发布要求。

既然,gulp 可以自定义流程来实现 html 生成,那么,笔者也可以单独写一个 webpack 插件来实现定制的流程。

Webpack 本身是一个非常灵活的系统,它是一个按照特定的流程执行的框架,在每个流程的不同的阶段提供了不同的钩子,通过各种插件去实现这些钩子的回调,来完成代码的打包,事实上,webpack 本身就是由无数原生插件组成的。在这整个流程中,笔者可以做各种不同的事情来定制它。

对于生成 html 的场景,通过增加一个插件,在 webpack 处理生成文件的阶段,将生成的 js、css 等资源文件,以及 ejs 模板和特殊配置整合到一起,再添加到 webpack 的 assets 集合中,便可以完成一次自定义的 html 生成。

compilation.hooks.processAssets.tap({
  name: 'TemplateHtmlEmitPlugin',
  stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL,
}, ()=> {
  // custom generation
  compilation.emitAsset(
    `${parsed.name}.html`,
    new sources.RawSource(source, true),
  );
  compilation.fileDependencies.addAll(dependencies);
});

在以上代码中,大家可以留意到最后一句:compilation.fileDependencies.addAll(dependencies),通过这一句,笔者可以将所有被自定义生成所依赖的文件加入的 webpack 的依赖系统中,那么当这些文件发生变更的时候,webpack 能够自动再次触发对应的生成流程。

3.4.一键化的开发体验

至此,各路编译都已经统一化,笔者可以用一个 webpack 编译整个项目了,watch 和 devServer 也可以一起 high 起来。不过,既然编译可以统一,何不让所有操作都整合起来呢?

基于 node_modules 不应该手工操作的假设,笔者可以创建 package.json 中依赖的快照,每次根据 package 的变化来判断是否需要重新安装,避免开发同学同步代码后的手动判断,跳过不必要的步骤。

public async install(force=false) {
  const startTime=performance.now();
  const lastSnapshot=this.readSnapshot();
  const snapshot=this.createSnapshot();

  const runs=this.repoInfos.map((repo)=> {
    if (
      this.isRepoInstallMissing(repo.root)
      || (!repo.installed
      && (force || !isEqual(snapshot[repo.root], lastSnapshot[repo.root])))
    ) {
      // create and return install cmd
    }
    return undefined;
  }).filter(script=> !!script);

  const { info }=console;
  if (runs.length > 0) {
    try {
      // 执行安装并保存快照
      await Promise.all(runs.map(run=> this.exec(run!.cmd, run!.cwd, run!.name)));
      this.saveSnapshot(snapshot);
    } catch (e) {
      this.removeSnapshot();
      throw e;
    }
  } else {
    info(chalk.green('Skip install'));
  }
  info(chalk.bgGreen.black(`Install cost: ${TimeUtil.formatTime(performance.now() - startTime)}`));
}

同样的,腾讯文档的本地调试是基于特殊的测试环境,通过 whislte 进行代理,这样的步骤也可以自动化,那么,对于开发来说,一切就很轻松了,一条命令,轻松搬砖~

不过,作为是一个复杂的系统,第一次使用,总需要初始化的吧,如果编译系统的依赖尚未安装,没有鸡,怎么生蛋呢?

其实不然,笔者不妨在整套编译系统的外层套个娃,做前端开发,node 总会先安装的吧?那么,在执行正在的编译命令之前,笔者执行一个只依赖于 node 的脚本,这个脚本会尝试执行主要命令,如果主命令直接 crash,说明安装环境尚未准备完毕,那么这个时候,对编译系统进行初始化就 ok 了。如此,就真的可以做到一键开发了。

const cmd='启动编译的命令';
const main=extraArg=> childProcess.execSync(`${cmd} ${extraArg}`, { stdio: 'inherit', cwd: __dirname });

try {
  main('');
} catch (e) {
  // 初始化
  main('after-initialize');
}

3.5.编译系统代码化

在这一次的重构过程中,笔者将原本的编译配置改为了由 ts 调用 webpack 的 nodeApi 来执行编译。代码化的编译系统有诸多好处:

  1. 使用 api 调用,可以享受 IDE 带来的代码提示,再也不会因为不小心在配置文件里面打了一个 typo 而调试一整天。
  2. 使用代码 api,能够更好的实现编译的结构,特别是有多重输出的时候,比起简单的 config 文件组合,更好管理。
  3. 使用代码化的编译系统,还有一个特别的作用,编译系统也可以写测试了!啥?编译系统要写测试?事实上,在腾讯文档历次的发布中,经历过数次莫名的 bug,在上线前的测试中,整个程序的表现突然就不正常了。相关代码,并没有任何改动,大家地毯式的排查了很久,才发现编译的结果和以前有微小的不同。事实上,在系统测试环境生成的前五个小时,一个编译所依赖的插件默默地更新了一个小版本,而笔者在 package.json 中对该插件使用的是默认的^xx.xx,流水线 install 到了最新的版本,导致了 crash。当时笔者得出了一个结论,编译相关的库需要锁定版本。但是,锁定版本并不能真正的解决问题,编译所使用的组件,总有升级的一天,如果保证这个升级不会引起问题呢?这就是自动化测试的范畴了。事实上,如果大家看看 Webpack 的代码,会发现他们也做了很多测试用例来编译的一致性,但是,webpack 的插件五花八门,并不是每一个作者在质量保障上都有足够的投入,因此,用自动化测试保证编译系统的稳定性,也是一个可以深入研究的课题。

4.编译提速

在涉及 typescript 编译的项目中,基本的提速操作,就是异步的类型检查,ts-loader 的 tranpsileOnly 参数和 fork-ts-checker 的组合拳百试不厌。不过,对于复杂的大型项目来说,这一套组合拳的启用过程未必是一帆风顺,不妨随着笔者一起看看,在腾讯文档中,启用快速编译的坎坷之路。

4.1.消失的 enum

在启用 transpileOnly 参数后,编译速度立即有了质的提升,但是,结果并不乐观。编译后,页面还没打开,就 crash 了。根据报错查下去,发现一个从依赖库导入的对象变成了 undefined,从而引起程序崩溃。这个变为 undefined 的对象,是一个 enum,定义如下:

export const enumScope{
  VAL1=0,
  VAL2=1,
}

为什么当笔者启用了 transpileOnly 后它就为空了呢?这和它的特殊属性有关,它不是一个普通的 enum,它是一个 const enum。众所周知,枚举是 ts 的语法糖,每一个枚举,对应了 js 中的一个对象,所以,一个普通的枚举,转化为 js 之后,会变成这样:

// ts
export enum Scope {
  VAL1=0,
  VAL2=1,
}

const a=Scope.VAL1;

// js
constScope={
  VAL1: 0,
  VAL2: 1,
  0: 'VAL1',
  1: 'VAL2',
};

const a=Scope.EDITOR;

如果笔者给 Scope 加上一个 const 关键字呢?它会变成这样:

// ts
export const enumScope{
  VAL1=0,
  VAL2=1,
}

const a=Scope.VAL1;

// js
const a=0;

也就是说,const enum 就和宏是等效的,在翻译成 js 之后,它就不存在了。可是,为何在关闭 transpileOnly 时,编译结果可以正常运行呢?其实,仔细翻看外部库的声明文件.d.ts,就会发现,在这个.d.ts 文件中,Scope 被原封不动地保留了下来。

// .d.ts
export const enumScope{
 VAL1=0,
 VAL2=1,
}

在正常的编译流程下,tsc 会检查.d.ts 文件,并且已经预知了这一个定义,因此,它能够正确的执行宏转换,而对于 transpileOnly 开启的情况下,所有的类型被忽略,由于原本的库模块中已经不存在 Scope 了,所以编译结果无法正常执行(PS:tsc 官方已经表态 transpile 模式下的编译不解析.d.ts 是标准 feature,丢失了 const enum 不属于 bug,所以等待官方支持是无果的)。既然得知了缘由,就可以修复了。四种方案:

方案一,遵循官方指导,对于不导出 const enum,只对内部使用的枚举 const 化,也就是说,需要修改依赖库。当然,腾讯文档本次 crash 所有依赖库确实属于自有的 sdk,但是如果是外部的库引起了该问题呢?所以该方案并不保险。

方案二,完美版,手动解析.d.ts 文件,寻找所有 const enum 并提取定义。但是,transpileOnly 获取的编译加速真是得益于忽略.d.ts 文件,如果笔者再去为了一个 enum 手工解析.d.ts,而.d.ts 文件可能存在复杂的引用链路,是极其耗时的。

方案三,字符串替换,既然 const enum 是宏,那么笔者可以手工通过 string-replace-loader 达到类似效果。不过,字符串替换方式依旧过于暴力,如果使用了类似于 Scope['VAL1']的用法,可能就猝不及防的失效了。

方案四,也是笔者最终所采取的方案,既然定义消失了,重新定义就好,通过 Webpack 的 DefinePlugin,笔者可以重新定义丢失的对象,保证编译的正常解析。

new DefinePlugin({
  Scope: {VAL1: 0,VAL2: 1 },
})

4.2.爱恨交加的 decorator 及依赖注入

很不幸,仅仅是解决了编译对象丢失的问题,代码依旧无法运行。程序在初始化的时候,依旧迷之失败了,经过一番调试,发现,初始化流程有一些微妙的不同。很明显,transpileOnly 开启的情况下,编译的结果发生了变化。 要解决这个问题,就需要对 transpileOnly 模式的实现一探究竟了。transpileOnly 底层是基于 tsc 的 transpileModule 功能来实现的,transpileModule 的作用,是将每一个文件当做独立的个体进行解析,每一个 import 都会被当做一个整体模块来看待,编译器不会再解析模块导出与文件的具体关系,举个例子:

// src/base/a.ts
export class A {}

// src/base/b.ts
export class B {}

// src/base/index.ts
export * from './a';
export * from './b'

// src/app.ts
import { A } from './base';

const a=new A();

如上是常见的代码写法,我们往往会通过一个 index.ts 导出 base 中的模块,这样,在其他模块中,笔者就不需要引用到文件了。在正常模式下,编辑器解析这段代码,会附带信息,告知 webpack,A 是由 a.ts 导出的,因此,webpack 在打包时,可以根据具体场景将 A、B 打包到不同的文件中。但是,在 transpileModule 模式下,webpack 所知道的,只有 base 模块导出了 A,但是它并不知道 A 具体是由哪个文件导出的,因此,此时的 webpack 一定会将 A、B 打包到一个文件中,作为一整个模块,提供给 App。对于腾讯文档,这个情况发生了如下变化(模块按照 1、2、3、4、5 的顺序进行加载,模块的视觉大小表示体积大小):

可以看到,在 transpileOnly 开启的情况下,大量的文件被打包到了模块 1 中,被提前加载了。不过,一般情况下,模块被打包到什么位置,并不应该影响代码的表现,不是么?毕竟,关闭 code splitting,代码是可以不拆包的。对于一般的情况而言,这样理解并没有错。但是,对于使用了 decorator 的项目而言,就不适用了。在代码普遍会转为 es5 的时代,decorator 会被转换为一个__decorator 函数,这个函数,是代码加载时的一个自执行函数。如果代码打包的顺序发生了变化,那么自执行函数的执行顺序也就可能发生了变化。那么,这又如何导致了腾讯文档无法正常启动呢?这,就要从腾讯文档全面引入依赖注入技术开始说起。

在腾讯文档中,每一个功能都是一个 feature,这个 feature 并不会手动初始化,而是通过一个特殊装饰器,注入到腾讯文档的 DI 框架中,然后,由注入框架进行统一的实例创建。举个例子,在正常的变一下,由三个 Feature A、B、C,A、B 被编译在模块 1 中,C 被编译到模块 2 中。在模块 1 加载时,workbench 会进行一轮实例创建和初始化,此时,FeatureA 的初始化带来了某个副作用。然后,模块 2 加载了,workbench 再次进行一轮实力创建和初始化,此时 FeatureC 的初始化依赖了 FeatureA 的副作用,但是第一轮初始化已经结束,因此 C 顺利实例化了。

当 transpileOnly 被开启式,一切变了样,由于无法区分导出,Feature A、B、C 被打包到同一个模块了。可想而知,在 Feature C 初始化时,由于副作用尚未发生,C 的初始化就失败了。

既然 transpileOnly 与依赖注入先天不兼容,那笔者就需要想办法修复它。如果,笔者将 app 中的引用进行替换:

// src/app.ts
import { A } from './base/a';

const a=new A();

模块导出的解析问题,是否就迎刃而解了?不过,这么多的代码,改成这样的引用,不但难看,反人类,工作量也很大。因此,让笔者设计一个 plugin/loader 组合在编译时来解决问题吧。在编译的初始阶段,笔者通过一个 plugin,对项目文件进行解析,将其中的 export 提取出来,找到每一个 export 和文件的对应关系,并储存起来(此处,可能大家会担心 IO 读写对性能的影响,考虑到现在开发人均都是高速 SSD,这点 IO 吞吐真的不算什么,实测这个 export 解析<1s),然后在编译过程中,笔者再通过一个自定义的 loader 将对应的 import 语句进行替换,这样,就可以实现在不影响正常写代码的情况下,保持 transpileOnly 解析的有效性了。

经过一番折腾,终于,成功地将腾讯文档在高速编译模式下运行了起来,达到了预定的编译速度。

5.Webpack5 升级之路

5.1.一些兼容问题处理

Webpack5 毕竟属于一次非兼容的大升级,在腾讯文档编译系统重构的过程中,也遇到诸多问题。

5.1.1.SplitChunks 自定义 ChunkGroups 报错

如果你也是 splitChunks 的重度用户,在升级 webpack5 的过程中,你可能会遇到如下警告:

这个警告的说明并不是十分明确,用大白话来说,出现了这个提示,说明你的 chunkGroups 配置中,出现了 module 同时属于 A、B 两组(此处 A、B 是两个 Entrypoint 或者两个异步模块),但是你明确指定了将模块属于 A 的情况。为何此时 Webpack5 会报出警告呢?因为从通常情况来说,module 分属于两个 Entrypoint 或者异步模块,module 应该被提取为公共模块的,如果 module 被归属于 A,那么 B 模块如果单独加载,就无法成功了。

不过,一般来说,出现这样的指定,如果不是配置错误,那就是 A、B 之间已经有明确的加载顺序。但是这个加载顺序,Webpack 并不知道。对于 entrypoint,webpack5 中,允许通过 dependOn 属性,指定 entry 之间的依赖关系。但是对于异步模块,则没有这么遍历的设置。当然,笔者也可以通过自定义插件,在 optimize 之前,对已有的模块依赖关系以及修改,保证 webpack 能够知晓额外的信息:

compiler.hooks.thisCompilation.tap('DependOnPlugin', (compilation)=> {
  compilation.hooks.optimize.tap('DependOnPlugin', ()=> {
    forEach(this.dependencies, (parentNames, childName)=> {
      const child=compilation.namedChunkGroups.get(childName);
      if (child) {
        parentNames.forEach((parentName)=> {
        const parent=compilation.namedChunkGroups.get(parentName);
        if (parent && !child.hasParent(parent)) {
          parent.addChild(child);
          child.addParent(parent);
        }
        });
      }
    });
  });
});

5.1.2.plugin 依赖的 api 已删除

Webpack5 发布后,各大主流 plugin 都已经相继适配,大家只要将插件更新到最新版本即可。不过,也有一些插件因为诸多缘由,一些插件并没有及时更新。(PS:目前,没有匹配的插件大多已经比较小众了。)总之,这个问题是比较无解的,不过可以适当等待,应该在近期,大部分插件都会适配 webpack5,事实上 webpack5 也是用了不少改名大法,部分接口进行转移,调用方式发生了改变,倒也没有全部翻天覆地的变化,所以,实在等不及的小插件不妨试试自己 fork 修改一下。

5.2.Module Federation 初体验

通常,对于一个大型项目来说,笔者会抽取很多公共的组件来提高项目间的模块共享,但是,这些模块之间,难免会有一些共同依赖,比如 React、ReactDOM,JQuery 之类的基础库。这样,就容易造成一个问题,公共组件抽取后,项目体积膨胀了。随着公共组件的增多,项目体积的膨胀变得十分可怕。在传统打包模型上,笔者摸索出了一套简单有效的方法,对于公共组件,笔者使用 external,将这些公共部分抠出来,变成一个残疾的组件。

但是,随着组件的增多,共享组件的 Host 增多,这样的方式带来了一些问题:

  1. Component 需要为 Host 专门打包,它不是一个可以独立运行的组件,每一个运行该 Component 的 Host 必须携带完整的运行时,否则 Component 就需要为不同的 Host 打出不同的残疾包。
  2. Component 与 Component 之间如果存在较大的共享模块,无法通过 external 解决。

这个时候,Module Federation 出现了,它是 Webpack 从静态打包到完整运行时的一个转变,Module Federation 中,提出了 Host 和 Remote 的概念。Remote 中的内容可以被 Host 消费,而在这个消费过程中,可以通过 webpack 的动态加载运行时,只加载其中需要的部分,对于已经存在的部分,则不作二次加载。(下图中,由于 host 中已经包含了 jQuery、react 和 dui,Webpack 的运行时将只加载 Remote1 中的 Component1 和 Remote2 中的 Component2。)

也就是说,公共组件作为一个 Remote,它包含了完整的运行时,Host 无需知道需要准备什么样的运行时才可以运行 Remote,但是 Webpack 的加载器保证了共享的代码不作加载。如此一来,就避免了传统 external 打包模式下的诸多问题。事实上,一个组件可以同时是 Host 和 Remote,也就是说,一个程序既可以作为主程运行,也可以作为一个在线的 sdk 仓库。关于 Module Federation 的实现原理,此处不再赘述,大家感兴趣可以参考探索 webpack5 新特性 Module federation 在腾讯文档的应用中的解析,也可以多多参考module-federation-examples这个仓库中的实例。

Webpack5 的 Module Federation 是依赖于其动态加载机制的,因此,在它的演示实例中,你都可以看到这样的结构:

// bootstrap.js
import App from "./App";
import React from "react";
import ReactDOM from "react-dom";

ReactDOM.render(<App />, document.getElementById("root"));

// index.js
import('./bootstrap.js');

而 Webpack 的入口配置,都设置在了 index.js 上,这里,是因为所有的依赖都需要动态判定加载,如果不把入口变成一个异步的 chunk,那如何去保障依赖能够按顺序加载内?毕竟实现 Moudle Federation 的核心是 基于 webpack_require 的动态加载系统。由于 Module Federation 需要多个仓库的联动,它的推进必然是相对漫长的过程。那么笔者是否有必要将现有的项目直接改造为 index-bootstrap 结构呢?事实上,笔者依然可以利用 Webpack 的插件机制,动态实现这一个异步化过程:

private bootstrapEntry(entrypoint: string) {
const parsed=path.parse(entrypoint);
const bootstrapPath=path.join(parsed.dir, `${parsed.name}_bootstrap${parsed.ext}`);
this.virtualModules[bootstrapPath]=`import('./${parsed.name}${parsed.ext}')`;
return bootstrapPath;
}

在上述的 bootstrapEntry 方法中,笔者基于原本的 entrypoint 文件,创建一个虚拟文件,这个文件的内容就是:

import('./entrypoint.ts');

再通过 webpack-virtual-modules 这个插件,在相同目录生成一个虚拟文件,将原本的入口进行替换,就完成了 module-federation 的结构转换。这样,配合一些其他的相应配置,笔者就可以通过一个简单参数开启和关闭 module-federation,把项目变成一个 Webpack5 ready 的结构,当相关项目陆续适配成功,便可以一起欢乐的上线了。

6.后记

对编译的大重构是笔者蓄谋已久的事情,犹记得加入团队之时,第一次接触到编译链路如此复杂的项目,深感自己的项目经历太浅,接触的编译和打包都如此简单,如今亲自操刀才知道,这中间除了许多技术难题,大项目必有的祖传配置也是阻碍项目进步的一大阻力。

Webpack 5 的 Beta 周期很长,所以在 Webpack5 发布之后,兼容问题还真不如预想的那么多,不过 Webpack5 的文档有些坑,如果不是使用 NodeApi 的时候,有类型声明,笔者绝对无法发现官方不少文档的给的参数还是 webpack4 的旧数据,对不上号。于是不得不埋着头调试源代码,寻找正确的配置方式,不过也因此收获良多。并且 Webpack5 还在持续迭代中,还存在一些 bug,比如 Module Federation 中使用了不恰当的配置,可能会导致奇怪的编译结果,并且不会报错。所以,遇到问题大家要大胆地提 issue。本次重构的经验就暂时说到这儿,如有不当之处,欢迎斧正。