通过分析ElementUI的源码,我学到了这些知识点(下)

2,040 阅读6分钟

本文接着上文

4、createElement 与 为什么要在Vue中使用jsx

createElement可能对于习惯于编写template的同学可能还比较陌生,这个方法用于编写render函数的场景,其中输入参数h便是createElement,请看下面的例子:

//已经省略无关代码
export default {
  name: 'ElCollapseTransition',
  functional: true,
  render(h, { children }) {
    const data = {
      on: new Transition()
    };

    return h('transition', data, children);
  }
};

这段代码位于element-ui/src/transitions/collapse-transition.js中。 在Vue的官方文档中有这样一节详细阐述了createElement的用法。 为什么我们要用createElement呢?有些时候我们需要动态的插入一个VNode,并且想要这个VNode跟我们正常写的模板一样具备事件响应的能力,比如说表格、表单等,每个表格列里面要涵盖不同的渲染内容的需求,我们是没办法事先就想好这个表格里面要展示的内容的,最容易想到的方式就是在运行的时候插入一段VNode。所以我们就可以对外提供一个函数,要求这个函数的入参是createElement,并且返回一个VNode。
ElementUI Tree自定义树节点的示例代码:

  renderContent(h, { node, data, store }) {
        return (
          <span class="custom-tree-node">
            <span>{node.label}</span>
            <span>
              <el-button size="mini" type="text" on-click={ () => this.append(data) }>Append</el-button>
              <el-button size="mini" type="text" on-click={ () => this.remove(node, data) }>Delete</el-button>
            </span>
          </span>);
      }

但是createElement有一个天生的不足,就是代码实在是太太太太太太冗余了。于是,为了改进直接编写createElement造成的代码冗余,便出现了jsx,所以jsx并不是React专属的啦。 上述ElementUI Tree自定义树节点的示例代码就是基于JSX编写的。我们把这段代码编写成原生的createElement函数的形式让大家来感受一下JSX的好处。

function renderContent(h, { node, data, store }) {
  return h(
    "span",
    {
      class: "ustom-tree-node",
    },
    h("span", {}, node.label),
    h("span", {}, [
      h(
        "el-button",
        {
          props: {
            size: "mini",
            type: "text",
          },
          on: {
            click: () => {
              this.append(data);
            },
          },
        },
        "Append"
      ),
      h(
        "el-button",
        {
          props: {
            size: "mini",
            type: "text",
          },
          on: {
            click: () => {
              this.remove(node, data);
            },
          },
        },
        "Delete"
      ),
    ])
  );
}

关于jsx的语法本文不做介绍。
最后再聊另外一个话题,来看一段代码,我们分别用template的语法和jsx的语法来编写。 使用template编写的源码:

<template>
  <div class="hello" :class="{ world: a }">
    <input type="text" v-model="val" />
  </div>
</template>

<script>
export default {
  data() {
    return {
      a: "",
      val: ""
    };
  },
  mounted() {
    this.a = 1;
  }
};
</script>

编译结果:

//以省略无关代码
 var o = function() {
      var e = this,
        t = e.$createElement,
        o = e._self._c || t;
      return o("div", { staticClass: "hello", class: { world: e.a } }, [
        o("input", {
          directives: [
            {
              name: "model",
              rawName: "v-model",
              value: e.val,
              expression: "val"
            }
          ],
          attrs: { type: "text" },
          domProps: { value: e.val },
          on: {
            input: function(t) {
              t.target.composing || (e.val = t.target.value);
            }
          }
        })
      ]);
    };

惊不惊喜,意不意外,template最终经过webpack编译之后,还是成了createElement函数的形式,哈哈哈。 然后我们再将其改写成jsx的形式: 源码如下:

<script>
export default {
  data() {
    return {
      a: "",
      val: ""
    };
  },
  mounted() {
    this.a = 1;
  },
  render(h) {
    return (
      <div class={{ hello: true, word: a }}>
        <input v-model={this.val} type="text" />
      </div>
    );
  }
};
</script>

编译结果如下:

//以省略无关代码
{
            data: function() {
              return { a: "", val: "" };
            },
            mounted: function() {
              this.a = 1;
            },
            render: function(e) {
              var r = this;
              return e("div", { class: { hello: !0, word: a } }, [
                e(
                  "input",
                  t()([
                    {
                      on: {
                        input: function(e) {
                          e.target.composing || (r.val = e.target.value);
                        }
                      },
                      attrs: { type: "text" },
                      domProps: { value: r.val }
                    },
                    {
                      directives: [
                        { name: "model", value: r.val, modifiers: {} }
                      ]
                    }
                  ])
                )
              ]);
            }
          }

所以现在大家应该明白为什么如果我们既没有写template并且也没有写render函数的时候,Vue会抛出一个提示说render函数和template必须要有一个呢。
需要注意一点的是JSX需要预编译,如果程序在运行的时候需要动态插入VNode的场景,仍然只能用createElement函数的形式
但实际项目中仍然优先考虑template的形式编写代码,一方面render函数入门门槛高,对于新手及其不友好,另一方面使用template编写的代码会经过webpack编译优化,在某些场景下性能会好一些。再者render函数里面不能使用指令(唯一一个v-model指令都是社区支持的,😁),编程体验不太好。但是render函数具有一定的可编程性,因此,需要根据开发场景适当的切换(我在实际项目几乎不会用到<component />组件,哈哈哈)。
还有一些笔者觉得在使用体验上不是特别友好的地方,Vue2中props和attrs是分开的,比如ElementUI里面写的组件接收的属性:

属性示例.png当我们切换到他的源码的时候你会发现,卧槽,你这个操作简直把我整神!!!

属性的小坑.png因此你需要明确的将placeholder写到attrs属性上去,这个placeholder才会生效。道听途说没有得到求证的消息,Vue3里面统一了attrs和props 另外一个区别就是,使用render函数使用插槽不是特别方便,因为Vue把作用域插槽跟普通插槽分别进行了处理。关于这个插槽,接下来我们会讲到。
Vue还提供了$createElement这个属性,这个属性其实就是我们的render函数的h,这个函数我只有在render函数的形式下才成功使用过。因为这个h入参的关系,假设业务特别复杂的情况下,我想要拆分render函数,但是拆分出来的函数老是第一个参数必须是h,实在是让人难以接受,于是,在拆分的函数中直接使用$createElement代替,避免了h参数的传递。

5、$slots 和$scopedSlots

首先,如果对插槽不太熟悉的同学请查阅Vue的官方文档
在上一节中我们聊到了Vue中createElement函数的使用场景,并且说到了Vue中的插槽问题。
什么时候使用插槽?什么时候使用createElement呢?
我的开发经验告诉我,如果你的API想要设计的反人类的话,就使用createElement函数吧,如果要设计的友好的话,就设计成插槽,哈哈哈。为什么这样说呢,上文说到了,当我们要在运行的时候动态的插入一段VNode的时候,我们可以借助Vue.component中组件的template的字段,然后再将其使用插槽插入,实际上我们把这个解析template得到VNode的过程交给Vue去处理了,否则我们将会面临写一大段的createElement生成VNode的问题。使用插槽的好处就是直观,可以方便的使用Vue的template的那些语法特性(最大的好处就是指令没有之一),代码简洁。
有些时候我们真的需要用一个JSON来表述表格的定义,从ElementUI-Table的使用体验上来说,感觉不是特别友好, 这儿我参考了AntDesign-Vue、iView、和ElementUI几个UI框架的在表格部分的设计,想要适应任何自定义表格列的组件。iview使用的是createElement形式实现自定义表格列的。ElementUI直接使用的是子组件(el-table-column)的形式配置表格列。AntDesign使用的是动态插槽(Vue3已经支持动态插槽)。回到ElementUI上来,我们先看看ElementUI-Table的slot-scope="xxx"(虽然这个写法已经废弃)这个语法特性是怎么实现的。 element-ui/packages/table/table-column.js第170行左右:

// 已经省略无关代码
    column.renderCell = (h, data) => {
      let children = null;
      if (this.$scopedSlots.default) {
        children = this.$scopedSlots.default(data);
      } else {
        children = originRenderCell(h, data);
      }
      const prefix = treeCellPrefix(h, data);
      const props = {
        class: 'cell',
        style: {}
      };
      if (column.showOverflowTooltip) {
        props.class += ' el-tooltip';
        props.style = {width: (data.column.realWidth || data.column.width) - 1 + 'px'};
      }
      return (<div { ...props }>
        { prefix }
        { children }
      </div>);
    };

大家可以看到,其实$scopedSlots就是一个返回VNode的函数($slots是一个VNode)。 有了这个基础之后,我们就大有可为了,我们可以判断当前$scopedSlots是否具有某个变量,如果有,则执行。在这儿,我贴一段我自己封装ElementUI表格时候对动态插槽的实现:

//这是我编写的表格列的组件中的一段代码
 setupSlots() {
      var slotFunc = this.$scopedSlots[this.prop]
      //如果当前表格列对应的prop存在这个具名作用于插槽的话,则使用插槽的内容进行渲染,否则使用table-column-wrapper组件进行渲染。
      return {
        default: ({ row, $index }) => {
          return typeof slotFunc === 'function' ? (
            slotFunc({ row, $index })
          ) : (
            <table-column-wrapper
              prop={this.prop}
              row={row}
              index={$index}
              config={this.config}
            />
          )
        },
      }
    }

我们花了很大的篇幅介绍$scopedSlots,接下来该介绍$slots了,我最开始使用JSX编写基础组件的时候,因为深入数据对象那一节,想当然的以为使用JSX只有一个$scopeSlots,当我某一天用JSX写ElementUI的el-input的时候发现代码并没有生效。 我编写的代码如下:

//已省略非关键代码
render(h) {
    const spanNode = h("span", "HelloWorld");
    return (
        <el-input
          v-model={this.val}
          type="text"
          {...{
            scopedSlots: {
              prepend: spanNode
            }
          }}
        />
    );
  }

后来我回想到,我们正常写template的时候,写插槽,都是将需要插入的组件放到当前组件的内部,然后添加slot属性。于是对这段代码进行改正:

 render(h) {
    return (
      <el-input v-model={this.val} type="text">
        <span slot="append">HelloWorld</span>
      </el-input>
    );
  }

这样就会生效了,但是这样又有了新问题,我们硬编码了slot,如何实现动态的slot呢,我是这样解决的:

 methods: {
    genSlots() {
      return Object.entries(this.$slots).map(([prop, vnode]) => {
        return <template slot={prop}>{vnode}</template>;
      });
    }
  },
  render(h) {
    return (
      <el-input v-model={this.val} type="text">
        {/* 不管你生成多少个slot,只能是我当前这个组件支持的slot,我才会给你展示 */}
        {this.genSlots()}
      </el-input>
    );
  }

目前我对动态插槽的实现思路,还没有经过对程序的运行性能影响的检验,忘读者酌情参考,如有纰漏,敬请指正。 基于以上思路就可以完全在Vue2中实现动态插槽。

6 $attrs 和 $listeners

在element-ui/packages/image/src/main.vue中,我们可以看到如下代码:

<template>
  <div class="el-image">
    <img
      v-else
      class="el-image__inner"
      v-bind="$attrs"
      v-on="$listeners"
      @click="clickHandler"
      :src="src"
      :style="imageStyle"
      :class="{ 'el-image__inner--center': alignCenter, 'el-image__preview': preview }">
  </div>
</template>

这两个属性对于封装基于第三方组件的组件的时候,是十分好用的,由于笔者在之前的文章有对这个知识点的讲解,感兴趣的朋友可以参考这里,此处将不再赘述。

7、aria-*

关于aria-*标签,我最开始使用ElementUI的时候,也比较好奇,为什么好端端的代码要多写一些“无意义”的标签呢,后来我通过查阅资料得知,这让网站的对无障碍访问支持有重要的意义。 下面这段话是我搬运的MDN的官方释义(我不光生产代码,还是代码的搬运工,哈哈哈哈):

Accessible Rich Internet Applications (ARIA) 是能够让残障人士更加便利的访问 Web 内容和使用 Web 应用(特别是那些由JavaScript 开发的)的一套机制。

ARIA 是一组特殊的易用性属性,可以添加到任意标签上,尤其适用于 HTML。role 属性定义了对象的通用类型(例如文章、警告,或幻灯片)。额外的 ARIA 属性提供了其他有用的特性,例如表单的描述或进度条的当前值。 因此,在后面的开发中,为了是我们的网站具备更好的无障碍访问特性,还得在合适的时机使用这些语法呢,害,知识又增加了呢。

8、i18n

ElementUI是支持多语言的一个UI库,但是通过研读它的代码发现,其实多语言的实现也并不复杂。 其核心代码位于element-ui/src/locale/index.js中。

//如果有应用了Vue其它对i18n支持的插件,则使用其它的多语言插件
let i18nHandler = function() {
  
  const vuei18n = Object.getPrototypeOf(this || Vue).$t;
  if (typeof vuei18n === 'function' && !!Vue.locale) {
    if (!merged) {
      merged = true;
      Vue.locale(
        Vue.config.lang,
        deepmerge(lang, Vue.locale(Vue.config.lang) || {}, { clone: true })
      );
    }
    return vuei18n.apply(this, arguments);
  }
};

export const t = function(path, options) {
  let value = i18nHandler.apply(this, arguments);
  //如果返回的值满足条件,说明用户使用了其它的i18n插件
  if (value !== null && value !== undefined) return value;

  const array = path.split('.');
  let current = lang;
  //否则使用ElementUI自己内置的语言替换模式  
  for (let i = 0, j = array.length; i < j; i++) {
    const property = array[i];
    value = current[property];
    if (i === j - 1) return format(value, options);
    if (!value) return '';
    current = value;
  }
  return '';
};

然后local保留解析方法(代码位于element-ui/src/mixins/locale.js) 代码如下:

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

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

在任何需要的地方(我以DatePicker为例)引入locale混入, 代码示例如下:

<!--已省略非关键代码-->
<template>
  <table
    :class="{ 'is-week-mode': selectionMode === 'week' }">
    <tbody>
    <tr>
      <th v-if="showWeekNumber">{{ t('el.datepicker.week') }}</th>
      <th v-for="(week, key) in WEEKS" :key="key">{{ t('el.datepicker.weeks.' + week) }}</th>
    </tr>
    </tbody>
  </table>
</template>

其既支持Vue主流的多语言插件,又提供了内置支持,这个手段对于我们在编写插件的时候还是有一定的借鉴意义的,毕竟其还是有可能面向外国用户使用的,如果预先不考虑这个问题,到时候又修改一堆的代码(忽然想到了邓爷爷的一句话,教育要面向现代化,面向世界,面向未来,哈哈哈😁)。

总结

这两篇文章通过ElementUI的源码分析,阐述了SCSS的一些高级用法以及CSS BEM和一些Vue高级API用法,很多内容都是来自于我在实际开发中的理解与心得体会,我可能阐述了的ElementUI所蕴含的编程技法的冰山一角都还算不上(有兴趣的读者可以查阅一下ElementUI下utils模块中的内容,也有一定的价值,可能会帮助你更好的使用ElementUI。),希望广大读者与我分享一些好的开发心得体会,毕竟只有交流才能碰撞出思维的火花,彼此都能进步,哈哈哈。
最后,在此感谢ElementUI团队为我们贡献出了如此优秀的开源项目,点赞三连。
由于笔者水平有限,写作过程中难免出现错误,若有纰漏,请各位读者指正,你们的意见将会帮助我更好的进步。本文乃笔者原创,若转载,请联系作者本人,邮箱404189928@qq.com🥰