ElementUI源码分析一:整体目录分析

2,295 阅读7分钟

“ 本文正在参加「金石计划 . 瓜分6万现金大奖」

一 源码文件分析

1 Element在vue中的本质


这是我们在使用element时在main.js中引入的。这是vue引入新组件的方式。
可以得出结论:Element-ui最终的形式是一个外部的新vue组件,可添加集成到Vue主组件中,element-ui这个组件内部也包含着很多小的Vue组件,例如Element经常使用的Button等组件。
Element既能全局引入,也能单独引入需要的组件,其本质就是因为,整个element-UI是一个大的vue组件,每个小的功能组件也是一个单独的vue组件。

2 源码查看方式

1 github克隆代码:element-ui源码链接
2 本地项目中查看:在使用过Element-ui 项目中,打开node_modules,找到element文件夹。

3 目录解读

这是我22.11.19在github中下载的element-ui 2.15.12版本代码文件,结构如下:
在分析代码时,主要分析的是一下五个文件/文件夹
image.png
补充:
locala 语言包,不同地区使用不同,会在src/index.js中使用。
transitions:放置动画配置的文件
image.png
补充:

  • .travis.yml:持续集成(CI)的配置文件,它的作用就是在代码提交时,根据该文件执行对应脚本,成熟的开源项目必备之一。
  • CHANGELOG:更新日志,土豪的 ElementUI 准备了 4 个不同语言版本的更新日志。
  • components.json:配置文件,标注了组件的文件路径,方便 webpack 打包时获取组件的文件路径。
  • element_logo.svg:ElementUI 的图标,使用了 svg 格式,合理使用 svg 文件,可以大大减少图片大小。
  • FAQ.md:ElementUI 开发者对常见问题的解答。
  • LICENSE:开源许可证,ElementUI 使用的是 MIT 协议,使用 ElementUI 进行二次开发的开发者建议注意该文件。
  • Makefile:在 .github 文件夹下的贡献指南中提到过,组件开发规范中的第一条:通过 make new 创建组件目录结构,包含测试代码、入口文件、文档。其中 make new 就是 make 命令中的一种。make 命令是一个工程化编译工具,而 Makefile 定义了一系列的规则来制定文件变异操作,常常使用 Linux 的同学应该不会对 Makefile 感到陌生。

下面我们就来逐步分析element源码的五个主要文件/文件夹

4 主入口文件index.js

./src/index.js
这个文件继承了所有elemen-ui的组件,为原型提供了一些方法,也是webpack打包的入口文件

/* Automatically generated by './build/bin/build-entry.js' */

import Pagination from '../packages/pagination/index.js';
// ...
// 引入组件

const components = [
  Pagination,
  Dialog,
  // ...
  // 组件名称
];

const install = function(Vue, opts = {}) {
  // 国际化配置
  locale.use(opts.locale);
  locale.i18n(opts.i18n);

  // 批量全局注册组件
  components.forEach(component => {
    Vue.component(component.name, component);
  });

  // 全局注册指令 使用vue.use方法的时候,会自动调用install函数,,所以只需要在install函数中
    //批量全局注册各种指令,组件,挂载全局方法。
  Vue.use(InfiniteScroll);
  Vue.use(Loading.directive);

  // 全局设置尺寸
  Vue.prototype.$ELEMENT = {
    size: opts.size || '',
    zIndex: opts.zIndex || 2000
  };

  // 在 Vue 原型上挂载方法
  Vue.prototype.$loading = Loading.service;
  Vue.prototype.$msgbox = MessageBox;
  Vue.prototype.$alert = MessageBox.alert;
  Vue.prototype.$confirm = MessageBox.confirm;
  Vue.prototype.$prompt = MessageBox.prompt;
  Vue.prototype.$notify = Notification;
  Vue.prototype.$message = Message;

};

/* istanbul ignore if */
if (typeof window !== 'undefined' && window.Vue) {
  install(window.Vue);
}

export default {
  version: '2.9.1',
  locale: locale.use,
  i18n: locale.i18n,
  install,
  CollapseTransition,
  // 导出组件
};

代码已经注释的很清楚,需要注意的是第一句话:
/* Automatically generated by './build/bin/build-entry.js' */
含义:该文件是由 build/bin/build-entry.js 生成的
需要重点注意的我都加了注释:

var Components = require('../../components.json');
var fs = require('fs');
var render = require('json-templater/string');
var uppercamelcase = require('uppercamelcase');
var path = require('path');
var endOfLine = require('os').EOL;

// 输出地址
var OUTPUT_PATH = path.join(__dirname, '../../src/index.js');
// 导入模板
var IMPORT_TEMPLATE = 'import {{name}} from \'../packages/{{package}}/index.js\';';
// 安装组件模板
var INSTALL_COMPONENT_TEMPLATE = '  {{name}}';
// 模板
var MAIN_TEMPLATE = `/* Automatically generated by './build/bin/build-entry.js' */

{{include}}
import locale from 'element-ui/src/locale';
import CollapseTransition from 'element-ui/src/transitions/collapse-transition';

const components = [
{{install}},
  CollapseTransition
];

const install = function(Vue, opts = {}) {
  locale.use(opts.locale);
  locale.i18n(opts.i18n);

  components.forEach(component => {
    Vue.component(component.name, component);
  });

  Vue.use(InfiniteScroll);
  Vue.use(Loading.directive);

  Vue.prototype.$ELEMENT = {
    size: opts.size || '',
    zIndex: opts.zIndex || 2000
  };

  Vue.prototype.$loading = Loading.service;
  Vue.prototype.$msgbox = MessageBox;
  Vue.prototype.$alert = MessageBox.alert;
  Vue.prototype.$confirm = MessageBox.confirm;
  Vue.prototype.$prompt = MessageBox.prompt;
  Vue.prototype.$notify = Notification;
  Vue.prototype.$message = Message;

};

/* istanbul ignore if */
if (typeof window !== 'undefined' && window.Vue) {
  install(window.Vue);
}

export default {
  version: '{{version}}',
  locale: locale.use,
  i18n: locale.i18n,
  install,
  CollapseTransition,
  Loading,
{{list}}
};
`;

delete Components.font;

var ComponentNames = Object.keys(Components);

var includeComponentTemplate = [];
var installTemplate = [];
var listTemplate = [];

// 根据 components.json 文件批量生成模板所需的参数
ComponentNames.forEach(name => {
  var componentName = uppercamelcase(name);

  includeComponentTemplate.push(render(IMPORT_TEMPLATE, {
    name: componentName,
    package: name
  }));

  if (['Loading', 'MessageBox', 'Notification', 'Message', 'InfiniteScroll'].indexOf(componentName) === -1) {
    installTemplate.push(render(INSTALL_COMPONENT_TEMPLATE, {
      name: componentName,
      component: name
    }));
  }

  if (componentName !== 'Loading') listTemplate.push(`  ${componentName}`);
});

// 传入模板参数
var template = render(MAIN_TEMPLATE, {
  include: includeComponentTemplate.join(endOfLine),
  install: installTemplate.join(',' + endOfLine),
  version: process.env.VERSION || require('../../package.json').version,
  list: listTemplate.join(',' + endOfLine)
});

// 生成入口文件
fs.writeFileSync(OUTPUT_PATH, template);
console.log('[build entry] DONE:', OUTPUT_PATH);

此文件使用了json-templater 来生成了入口文件
它通过引入 components.json 这个我们前面提到过的静态文件,批量生成了组件引入、注册的代码。
优点:我们不再需要每添加或删除一个组件,就在入口文件中进行多处修改,使用自动化生成入口文件之后,我们只需要修改一处即可。

5 交互操作优化方法directives

directives文件夹中只有两个js文件,分别是:
mousewheel.js :滚轮事件优化
repeat_click.js:单击事件优化
代码也比较简短 就直接在代码中进行注释说明

import normalizeWheel from 'normalize-wheel';

const isFirefox = typeof navigator !== 'undefined' && navigator.userAgent.toLowerCase().indexOf('firefox') > -1;

const mousewheel = function(element, callback) {
  if (element && element.addEventListener) {
    element.addEventListener(isFirefox ? 'DOMMouseScroll' : 'mousewheel', function(event) {
      const normalized = normalizeWheel(event);
      callback && callback.apply(this, [event, normalized]);
    });
  }
};

export default {
  bind(el, binding) {
    mousewheel(el, binding.value);
  }
};

百度后得知:mousewheel.js只是一个jQuery中的一个鼠标滚轮插件,该文件的主要作用是:
对鼠标滚动事件进行了优化,使用normalize-wheel这个库来解决不同浏览器之间的兼容性来获取x方向和y方向的滚动偏移量。

import { once, on } from '@/utils/dom';

export default {
  bind(el, binding, vnode) {
    let interval = null;
    let startTime;
    // 获取指令绑定的事件函数
    const handler = () => vnode.context[binding.expression].apply();
    // 定义一个清除定时器的函数
    const clear = () => {
        // 间隔时间小于100毫秒时,继续执行回调函数
      if (Date.now() - startTime < 100) {
        handler();
      }
      clearInterval(interval);
      interval = null;
    };

    // 添加点击事件
    on(el, 'mousedown', (e) => {
      if (e.button !== 0) return;
     //   缓存点击时的时间
      startTime = Date.now();
      // 添加鼠标弹起的事件
      once(document, 'mouseup', clear);
      // 清除定时器
      clearInterval(interval);
      // 100毫秒执行一次回调函数
      interval = setInterval(handler, 100);
    });
  }
};

该文件就是一个用于优化单击事件的指令,我们平时点击时,正常的点击逻辑是这样的:
当用户按住鼠标左键时,会触发mousedown的回调。但当一直按住鼠标左键不松手时,就不会触发mousedown的回调
使用该指令就是为了实现一直按住鼠标左键不松手时,也能执行对应的事件,这指令主要用在InputNumber组件中,当鼠标点击-或+不松开时,数字可以持续的进行加减。

6 mxins外部方法

此文件下共有三个js文件:
image.png
先上总结:
在mxins外部混入的方法中,一共有以下几个导出的方法:
dispatch方法:子组件发送消息给上层组件
boradcast方法:上层组件通知,
focus方法:聚焦
getMigratingConfig方法:非生产环境下判断props和events命名规范(不允许驼峰)

(1)emitter.js

源码及注释

function broadcast(componentName, eventName, params) {
  this.$children.forEach(child => {
    var name = child.$options.componentName;

    if (name === componentName) {
      child.$emit.apply(child, [eventName].concat(params));
    } else {
      broadcast.apply(child, [componentName, eventName].concat([params]));
    }
  });
}
export default {
  methods: {
 dispatch(componentName, eventName, params) {
      // 当前父组件
      var parent = this.$parent || this.$root;
      // 当前父组件的组件名
      var name = parent.$options.componentName;

      // 通过$parent,一直向上找,直到组件名等于componentName
      while (parent && (!name || name !== componentName)) {
        parent = parent.$parent;

        if (parent) {
          name = parent.$options.componentName;
        }
      }
      if (parent) {
        // 如果找到目标组件,那么调用目标组件的$emit方法
        parent.$emit.apply(parent, [eventName].concat(params));
      }
    },
 broadcast(componentName, eventName, params) {
  // 遍历所有子组件
  this.$children.forEach(child => {
    var name = child.$options.componentName;
    // 找到组件名为componentName的子组件,并调用该子组件的$emit方法;
    // 否则,继续递归
    if (name === componentName) {
      child.$emit.apply(child, [eventName].concat(params));
    } else {
      broadcast.apply(child, [componentName, eventName].concat([params]));
    }
  });
}
};

在分析代码后,发现此文件主要实现了dispatch和broadcast,特别是dispatch似曾相识,那必须要一探究竟了

dispatch方法:子组件发送消息给上层组件
boradcast方法:上层组件通知下层组件。

(2)focus.js

export default function(ref) {
  return {
    methods: {
      focus() {
        this.$refs[ref].focus();
      }
    }
  };
};

使用focus方法在注册过refs的集合中获取ref对象,并聚焦。

.focus()事件 将用户的光标自动放到焦点上去,无需用户进行单独的操作

(3)locale.js

import { t } from 'element-ui/src/locale';

export default {
  methods: {
    t(...args) {
      return t.apply(this, args);
    }
  }

// 国际化工具配置,没啥好讲的

(4) migrating.js

对发生改动的props或eventName发出警告

import { kebabCase } from 'element-ui/src/utils/util';

export default {
  mounted() {
    if (process.env.NODE_ENV === 'production') return;
    if (!this.$vnode) return;
    const { props = {}, events = {} } = this.getMigratingConfig();
    const { data, componentOptions } = this.$vnode;
    const definedProps = data.attrs || {};
    const definedEvents = componentOptions.listeners || {};

    for (let propName in definedProps) {
      propName = kebabCase(propName); // compatible with camel case
      if (props[propName]) {
        console.warn(`[Element Migrating][${this.$options.name}][Attribute]: ${props[propName]}`);
      }
    }

    for (let eventName in definedEvents) {
      eventName = kebabCase(eventName); // compatible with camel case
      if (events[eventName]) {
        console.warn(`[Element Migrating][${this.$options.name}][Event]: ${events[eventName]}`);
      }
    }
  },
  methods: {
    getMigratingConfig() {
      return {
        props: {},
        events: {}
      };
    }
  }
};

代码也比较简单:开始引入了一个kebabCase函数,在until.js中找到该函数功能如下:

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

kebabCase函数是把驼峰的命名(abAb)改为下划线连接ab_ab的形式, 在非生产环境中如果出现就会报错警告

7 utils 工具方法集合

utils文件夹中放置了许多组建中通用的函数方法,这里就不一一展开,后期在组件源码的学习中在看。
写结几个常见的:
dom.js:事件监听、事件注销、一次执行、是否有XXX class 、增加class、移除某class、获取style、设置style、是否滚动、获取滚动的节点、某元素是否包含在某容器里。
types.js:事件监听、事件注销、一次执行、是否有XXX class 、增加class、移除某class、获取style、设置style、是否滚动、获取滚动的节点、某元素是否包含在某容器里。

8 packags.json

每个项目这个文件夹都是中点,可以在里面找到我们想要的很多解决方案
对于element-ui中的配置文件,需要关注的是以下几个重点:
1 files:
image.png
files是对外发布的内容:lib是打包后生成的文件目录

2 script
script关注的重点主要有:
image.png

dist

命令展开后如下(后面为注释):

"dist": "
 npm run clean && //删除上次打包生成的文件及目录
 npm run build:file &&
 npm run lint &&// 利用eslint。根据.eslint和.eslintignore文件,检测代码规范
 webpack --config build/webpack.conf.js &&
 webpack --config build/webpack.common.js &&
 webpack --config build/webpack.component.js &&
 npm run build:utils && //设置BABEL_ENV=utils
 npm run build:umd && //利用babel-core及插件add-module-exports和transform-es2015-modules-umd处理src/locale/lang下的文件,生成umd格式的文件;
 npm run build:theme//构建主题
"

npm run build:file:
利用postcss,根据package/theme-chalk/src/icon.scss,往example目录生成icon相关的信息;
利用json-templater/string模板引擎,根据根目录下components.json,往src目录下生成index.js文件,index.js主要是引入packages目录下的组件及install(vue插件)方法,并对外export;
利用正则,根据examples/i18n/page.json和examples/pages/template,生成不同语言的文件,examples的内容相当于element UI官网。
webpack --config build/webpack.conf.js:
入口文件:src/index.js(npm run build:file生成)
输出:以umd形式输出到lib/index.js;
loader:babel-loader处理jsx等文件;vue-loader处理packages下面的vue组件
webpack --config build/webpack.common.js:
入口文件:src/index.js(npm run build:file生成)
输出:以commonjs2形式输出到lib/element-ui.common.js;
loader:babel-loader处理jsx、babel和es6等文件;vue-loader处理packages下面的vue组件;style-loader和css-loader处理css文件;以url-loader处理图片等;
**webpack --config build/webpack.component.js:
**入口文件:components.json,包含packages下的组件;
输出:把packages下的组件,以commonjs2形式分别输出到lib目录;
loader:babel-loader处理jsx、babel和es6等文件;vue-loader处理packages下面的vue组件;style-loader和css-loader处理css文件;以url-loader处理图片等;

dev

"npm run bootstrap && // 启动bootstrap
   npm run build:file && //前面讲过
   cross-env NODE_ENV=development webpack-dev-server --config build/webpack.demo.js & 
   node build/bin/template.js",//开发环境下启动webpack打包

test

通过karma测试工具和mocha, sinon-chai测试框架进行单元测试

pub

"pub": "
 npm run bootstrap &&
 sh build/git-release.sh &&
 sh build/release.sh &&
 node build/bin/gen-indices.js &&
 sh build/deploy-faas.sh
"
  • npm run bootstrap:
    安装依赖,注意的是vue是以peerDependencies的形式配置的
  • sh build/git-release.sh:
    git checkout dev
  • sh build/release.sh:
    a.checkout master分支,并合并dev分支;
    b.通过npx临时安装select-version-cli,与开发者进行交互,更新版本信息;
    c.执行npm run dist;
    d.测试ssr;
    e.进入packages/theme-chalk目录,利用npm version和npm publish,发布主题(packages/theme-chalk是个基于gulp的工程),由此可见elementUI的主题是可以独立发布的,不过会保证version跟elementUI保持一致;
    f.退回到根目录,提交代码并通过npm version更新版本(更新package.json中的版本号);
    g.在当前分支(a步骤切换到master)push代码,然后checkout dev分支,并rebase master分支,最后push代码;
    h.如果version为beta,则通过npm publis --tag打上标签,否则直接publish
  • node build/bin/gen-indices.js
    利用algoliasearch进行搜索,需要把examples/docs/下的.md文件内容以一定格式上传给algolia

参考资料:

ElementUI结构与源码

Element-UI库 源码架构浅析