或许我们并不需要 default exports

1,448 阅读13分钟

免责声明

  • 以下观点基于笔者的实践经验,无意于以偏概全
  • 请结合你的实践判断是否适用
  • 欢迎指出错漏之处

Named exports and Default exports

众所周知,ES 提供了两种 exports 语法,如果你还不熟悉,可先参考 MDN JavaScript export 进行了解。 我们直接简述两者的特点

Named exports

  • ES Modules 的基本 export 形式
  • 每个模块有任意数量的 export 值
  • export 值是具名的
    • 必须用使用其原名进行 import
    • 可以利用 as 进行重命名
// 定义 : user-list.js
export const userList = { /* ... */ }

// 引入方 : my-page.js
import {userList} from './user-list.js'

Default exports

  • (主要是)为了能更好的与现存的 CommonJS and AMD modules 交互而设计的 export 形式
  • 每个模块只能有 1 个 default export 值
  • export 值是匿名的
    • 可使用任意命名进行 import
    • 或者理解为“必须在 import 时进行重命名”
  • 常见于模块只有 1 个输出值,或将某值作为模块的 “main” 输出
// 定义 : user-list.js
export default { /* ... */ }

// 引入方 : my-page.js
import userList from './user-list.js'

这些或许不是我们应当使用 default export 的理由

如题所指,我们需要 default export 么?

我们遵循 1 个文件只输出 1 个东西的模式,并且将文件命名为其输出的东西, 如此我们能从文件名即能明确 “这是什么” 而不用进入文件

按照笔者的经验,这是最常见,也是最主要的坚持使用 default export 的理由。 笔者认为这有道理,但本身还可以再往下推敲一点。

关于 “1 个 文件只输出 1 个东西”

  • 这只是一种 “约定”,不需要,也无法靠 default export 来确保
    • 即,除非自研工具进行诸如 “模块中必须且仅能出现一次 export default , 且不能出现任何其他 export 语法” 之类的代码限制
    • 否则,“一个同学因为 ‘不知道此约定’ 而在一个模块中 export 多个东西” 理论上是不受控制的
  • 在实践中,这是一个颇为严苛的约定,而且随着研发的持续迭代,一般会越来越难以遵守
import React from 'react'

// 一般来说 component props type 都会需要 export
export interface ActionButtonProps {
    type?: 'normal' | 'important'
}

// 即使一开始认为这是一个 “模块内部使用的常量” 而没有 export,
// 但不难看出,我们也会预期这未来可能需要将其输出被别处引用的
export const ACTION_BUTOON_TYPE_DEFAULT = 'normal'
// const ACTION_BUTOON_TYPE_DEFAULT = 'normal'

export default function ActionButton (props: ActionButtonProps) {
    const {type = ACTION_BUTOON_TYPE_DEFAULT} = props
    // ...
}

关于 “default export 的是此模块的 main thing,而不是 only thing”

这对应于 “一个模块有一个事实上更主要东西” 这个模式,日常研发中很常见, 比如上一段代码实例中,就是一个关于 ActionButton 组件的模块,输出物中的 ActionButton 事实上“主要输出物”。

笔者注:这里说 “事实上”,是指笔者认为形式上,模块中各个内容物地位是平等, 但事实上很多情况我们需要以其中一个“主要输出物”作为模块的主干,来作为组织其内容物的线索

但是, 有 "主输出物", 不等价于 "必须使用 default export 来标识它",实际上的一些约定俗称已经足以表示

  • 命名:模块名暗示了其“主要输出物”,比如当看到 action-button.tsx ,表名这是一个 “关于 action-button 的模块” 我们自然知道里面的 ActionButton 就是“主要输出物”
  • 位置:“如果一个模块有“主要输出物”,那么通常我们会把他放在代码的最后一块” 应该也是大多数 coder 会自然遵守的习惯吧

另一方面,“模块有一个主要输出物” 在实践中往往不能成为项目中的唯一的模式, “将关联的东西 group 到一个模块里”,也是常用的组织模式,比如

  • 关于某个主题的 utils
  • 关于某项业务的功能组合(功能间难分主次)
  • ...

小结一下笔者的此段的观点

  • 模块里的内容物是地位是平等的
  • 有时候,模块中会有事实上的“主要输出物”,但并不需要用 default export 来对其进行强调和 “认证”,其“主要输出物”身份更多是不言而喻的,强调与认证与否并不会产生什么实质的影响

关于 “输出什么文件名就是什么”

以 “default 输出的不是唯一” ,作为引导,

当我们 “以输出物来命名文件(即以 default export 之物的名称来命名文件)”,比如

  • export default class I18n {} 会命名为 I18n.ts
  • export default function createI18n() {} 会命名为 createI18n.ts
  • export default const i18nSingleton = new I18n() 会命名为 i18nSingleton.ts

这其实是 “用模块的主要输出物来命名文件”,这有问题么?

此命名规则本身没问题

airbnb js 规范中甚至有专门的对应规则,见 github.com/airbnb/java… 23.6 ~ 23.7

但笔者认为,这种命名模式在 “文件 - 模块 - 内容物(输出物)” 三层概念中:

  • 模糊了模块的概念

    • 模块是值的容器,文件作为模块的实际载体
    • 而文件的命名却是以模块的“主要输出物”来命名,那其他值概念上是归属于 “模块”,还是归属于 “模块中的这个 main”?
  • 一个文件时而代表一个类,时而代表一个函数,时而代表一个单例,如果命名取得不妥当就没法通过命名辨别

    • 比如 i18n.ts 其实也不知道这是 一个 i18n util 函数,还是一个 i18n 单例
    • 也就是说 “看到文件名就知道输出了啥” 其实也没那么确定,i18n.ts 到头来也只是表示 “这是一个关于 i18n 的模块”

以上观点是偏概念层面的理解,可能会让人觉得有点绕, 没关系,接下来我们还得看看这这种命名模式更实际的一些问题

当我们 “使用内容物来命名文件”,实际是对 “文件命名” 执行 “代码命名规范”:

  • 变量,单例 使用 camelCase
  • 类名,构造函数 使用 PascalCase
  • 常量 使用 MACRO_CASE (aka SCREAMING_SNAKE_CASE)
  • ...

问题是,对 “文件命名” 执行 “代码命名规范”,合适么?

首先陈列前端项目两个基本 “事实”:

  • mac 和 windows 是最常用的开发环境,同时不乏需要在 linux 下运行项目的场景(比如 ci)
  • 绝大部分没法脱离 nodejs,npm

对,机智如你,一定已经想到我们要拿命名中 “字母大小写以及字符” 来说事了

操作系统的大小写

众所周知,windows 和 macOS在默认情况下 都是大小写不敏感的,而 linux 则是敏感的。

如果我们的项目中有 ./src/I18n.ts,而我们在代码中写为 import I18n from './i18n'

一般情况下我们使用 windows 和 macOS 在本地开始时是不会暴露问题的,问题的暴露会被推迟到 ci linux 环境中构建时。

虽然发生频率不高,但一旦发生就得去定位解决。对于日常研发浪费时间事小,影响心情事大,而且如果运气不好,真发生在一些突发情况或需紧急上线时,就更尴尬了。

实践中,我们可能得益于一些工具,webpack CaseSensitivePathsPlugin 来提前暴露问题(实际上 webpack 现在也会直接给出 warning), 以减免实际造成的影响,但这并不影响此论点的本质

目录/文件路径命名的惯例

  • 虽然 nodejs 本身没有强制的 “目录及文件名命名规范”,但观察其内置模块以及已广泛采用的 npm 模块,模块名是小写的

  • 这其实来自于 npm 的 “package name 不能包含大写字母” 的要求(详见 docs.npmjs.com/files/packa…

  • 当然我们可以仅在 “pakcage name” 遵循此要求,但笔者认为这样的做法,让规则变得更复杂但收益寥寥

    • 其实我们应该预期任何目录/文件,都有被提取为一个 package 的可能,参考 monorepo 管理模式,应该不难理解
    • 而且,这也不只是 “目录名” 的规则,比如类似 src/I18n.ts 重构为 src/I18n/index.ts 的情况,所以实际上文件名也必须遵循一样的规则

至此,小结一下观点:

  • 由于 “目录/文件名中允许大写字母” 不是一个好模式,这使得 “对文件名应用代码命名规则” 不是一个好模式

相对的,如果我们这么理解 “文件-模块-内容物” 这三层关系,笔者认为更简单且没有额外问题:

  • 这三者的关系是,文件是模块的载体,模块是内容物(代码) 的抽象容器
  • “文件”和“模块” 可以理解为同一个东西的两面 —— “实体”与“抽象概念”, 即可理解为 “文件即模块” ,所以文件名即模块名
  • 不去模糊模块这层概念,即我们不升级模块中某个内容物作为 “特殊的/主要的/ default 的”,所以就不存在所谓的 “使 default export 之物作为文件名”

综上

  • 从名命名角度看,“文件名/模块名” 是独立于内容物的命名,不直接关联(某个)内容物,自然也不存在 “以 default export 作为文件名/模块名” 的必要性。
  • 反过来从模块及内容物角度看,一个模块中的内容物是平等的,所谓 default,也只是名称而已,自然也就没有存在的必要性

即笔者认为 “使用 default export 是为了用文件名表示输出物” 这本身并不像直觉的那么好,有概念理解上的问题,也有些实际问题

default export 的一些问题

上面说了一些偏观念上的问题,可能各有不同的感受,以下我们看看 default export 在实践层面的一些问题(直接对比 named export),应该更有说服力。

注,以下话题会基于 TypeScript 进行,对于 JavaScript 会少一些和 ts 特性相关的问题,但八九不离十

可发现性与自动补全

由于 default export 是匿名的,IDE 并不能通过 “名字” 来帮我们进行检索和自动补全

default-export-not-found.gif

named-export-auto-imported.gif

诚然,一些强大的 IDE 比如 WebStorm 即使是 default export 也能进行进行检索和自动补全,但那是终归是要耗费更多资源

多重命名

对于 default export,每处 import 的地方实际上都进行了一次重命名。

这理论上相当于 “我们原则上放弃了保持命名的一致性”, 且不说拼写错误,这种小问题,我们总的来说不会希望项目中的同一个东西在一处叫做 createI18n() , 而在别处 new18n()generateI18n()

更重要的是,export default 的东西无法进行关于命名的重构。

default-export-refactor.gif

而相对的, named export,则相当于“原则上贯彻项目范围内的一致且明确的命名”。 这使得关于“命名语义的渐进式重构” 得以轻松自然的进行。

named-export-refactor.gif

简而言之,named export 可以使输出物在各模块间 “stay in touch” 保持实际的关联, 而 default export 只是向被 import 模块进行了值输送

无法 Tree shaking 的风险

我们的实践中其实不乏需要输出 “集合” 的情况, 比如 utils 集。 以下 export default 的是一个无法进行 shaking 的 “(大)对象整体"。 从 Tree shaking 的角度考虑,妥妥是一个反模式。

function omit() {}
function pick() {}
// ...
// no tree shaking
export default {
    omit,
    pick,
// ...
}

另一边, named export 简洁,自然,无风险

// === 写法1 ===
export function omit() {}
export function pick() {}

// === 写法2 ===
function isEqual() {}
function cloneDeep() {}
export {isEqual, cloneDeep}

无实际语义的 .default

default export 设计的主要目的是为了 “帮助 es module 与存世的 CommonJS 和 AMD modules 进行交互” 。(以下叙述简化为 CommonJS)

一方面对 es modules 来说, CommonJS modules 的输出将以 defaut export 被 es module import, 这使得我们可以通过 import _ from 'lodash'; 直接 import 使用 CommonJS modules。

但另一方面对于 CommonJS modules 来说,并不需要 es module 进行 default export。

export default 的 es modules 在被 CommonJS require 时不得不处理一个 “无语义的 default”

// require default exported es modules
const {default: I18n} = require('./i18n');
const parseUrl = require('./utils/parse-url').default;

// require named exported es module
const {createAjax} = require('./utils/ajax');

诚然,我们可以利用工具手段来解决(如 @babel/plugin-transform-modules-commonjs),但这又增加的认知负担了不是吗?(要有所约定,并记住“此项目进行了约定,而别的项目可能没有”)

况且,还有不太好用工具处理的情况:

async function loadDict(locale: 'zh_cn' | 'en_us') {
  const {default: dict} = await import(`./dict/${locale}.js`);
}
  • re-export
// default export
export {default as pick} from './pick';

// named export
export * from './omit';    // single export function omit() {}
export * from './react-hooks'; // a group which contain several hooks

如果不使用 default export 会有什么影响

有没有不使用 default export 做不到的事,或非 default export 不可的事?

  • 不使用 default export 做不到的事 —— 没有
  • 非 default export 不可的事 —— 有,但其实是来自于第三方,而不是实践模式中的必然。属于原则上没有,但实际存在,比如 React.lazy()
import React from 'react';

export const routes = [
    {
        key: 'some-page',
        path: 'home',
        component: React.lazy(() => import('./home/page')),
    },
];

// node_modules/@types/react/index.d.ts
function lazy<T extends ComponentType<any>>(
    factory: () => Promise<{ default: T }>
): LazyExoticComponent<T> {};

损失了使用 default export 带来的便利?

除去上文提及的一些笔者不赞同的 “对概念、认知上的惯性的顺从”,笔者认为 default export 并没有带来任何实际便利。 而 “可以直接重命名”、“不用写 {} ” ,在笔者看来,严格来说并不算是有效的研发便利。

我们应该贯彻 “只使用 named export”

最后,通过上文对 default export 的“数落”,一拉一踩,无非就想推荐 “我们应只使用 named export” 这么一个观点,这里总结一下好处:

明确、全文一致、保持关联的命名,意味着更好/更快的:

  • auto import
  • 代码检索
  • 代码导航
  • 重构
  • fail early,solve early

更简单、有效、易于贯彻的规则(文件-模块-内容物的命名、模块间 import, export 形式、命名原则),意味着更少一些打断我们 coding 思路的事情(stay focused):

  • 需要重新确认
    • import list from './list' but what list?
    • import userList from '../common/user' or import {list as userList} from '../common/user' ?
    • ...
  • 需要决策
    • 这是个组件,组件作为 default export 相应的也要作为文件名
    • 这个模块既提供“类”,也提供“一个创建好的单例”,但哪个应该作为 default 呢? 日后期望的变化如何?如果变化走向不符合预期呢?
    • 这里是个集合统一输出,无法 default 输出了,但只看模块名的话,可能看不出,要想法办法尽量(向其他成员)明确这里没有 default export
    • ...
  • 需要记忆
    • 不能 export default 一个大集合,否则 tree-shaking 会挂
    • ...

实践规则的话,也简单,概括为两条即可:

文件即模块,模块名为文件名,遵循全小写 kebab-case 不应简单提升某个内容物来命名模块

  • 比如 i18n.ts 指此模块名为 i18n,指这是一个关于 “i18n” 的模块
  • 其中可能会输出内容物 class I18n {}, function createI18nSingleton(),若干
  • 但不要将此模块命名为 I18n.ts 或者 createI18nSingleton.ts

如无特殊需要,总是使用 named exported default export 仅在有特殊需要时,作为一个特殊的 named export alias 添加,如上文提及的 React.lazy() 仅接受的模块的 .default 情况:

// === src/page/home.ts ===
import React from 'react'
export function PageHome() {}
// for React.lazy()
export default PageHome
// === src/routes.ts ===
import {RouteProps} from 'react-router-dom';
export const routes: RouteProps[] = [
    {
        path: 'home',
        component: React.lazy(() => import('./page/home')),
    }
];

(全文完)

Resources