众所周知,ES 提供了两种 exports 语法,如果你还不熟悉,可先参考 MDN JavaScript export 进行了解。 我们直接简述两者的特点
// 定义 : user-list.js
export const userList = { /* ... */ }
// 引入方 : my-page.js
import {userList} from './user-list.js'
// 定义 : user-list.js
export default { /* ... */ }
// 引入方 : my-page.js
import userList from './user-list.js'
如题所指,我们需要 default export 么?
我们遵循 1 个文件只输出 1 个东西的模式,并且将文件命名为其输出的东西, 如此我们能从文件名即能明确 “这是什么” 而不用进入文件
按照笔者的经验,这是最常见,也是最主要的坚持使用 default 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// ...
}
这对应于 “一个模块有一个事实上更主要东西” 这个模式,日常研发中很常见, 比如上一段代码实例中,就是一个关于 ActionButton 组件的模块,输出物中的 ActionButton 事实上“主要输出物”。
笔者注:这里说 “事实上”,是指笔者认为形式上,模块中各个内容物地位是平等, 但事实上很多情况我们需要以其中一个“主要输出物”作为模块的主干,来作为组织其内容物的线索
但是, 有 “主输出物”, 不等价于 “必须使用 default export 来标识它”,实际上的一些约定俗称已经足以表示
action-button.tsx ,表名这是一个 “关于 action-button 的模块” 我们自然知道里面的 ActionButton 就是“主要输出物”另一方面,“模块有一个主要输出物” 在实践中往往不能成为项目中的唯一的模式, “将关联的东西 group 到一个模块里”,也是常用的组织模式,比如
小结一下笔者的此段的观点
以 “default 输出的不是唯一” ,作为引导,
当我们 “以输出物来命名文件(即以 default export 之物的名称来命名文件)”,比如
export default class I18n {} 会命名为 I18n.tsexport default function createI18n() {} 会命名为 createI18n.tsexport default const i18nSingleton = new I18n() 会命名为 i18nSingleton.ts这其实是 “用模块的主要输出物来命名文件”,这有问题么?
此命名规则本身没问题
airbnb js 规范中甚至有专门的对应规则,见 github.com/airbnb/java… 23.6 ~ 23.7
但笔者认为,这种命名模式在 “文件 - 模块 - 内容物(输出物)” 三层概念中:
i18n.ts 其实也不知道这是 一个 i18n util 函数,还是一个 i18n 单例* 也就是说 “看到文件名就知道输出了啥” 其实也没那么确定,i18n.ts 到头来也只是表示 “这是一个关于 i18n 的模块”以上观点是偏概念层面的理解,可能会让人觉得有点绕, 没关系,接下来我们还得看看这这种命名模式更实际的一些问题
当我们 “使用内容物来命名文件”,实际是对 “文件命名” 执行 “代码命名规范”:
问题是,对 “文件命名” 执行 “代码命名规范”,合适么?
首先陈列前端项目两个基本 “事实”:
对,机智如你,一定已经想到我们要拿命名中 “字母大小写以及字符” 来说事了
众所周知,windows 和 macOS在默认情况下 都是大小写不敏感的,而 linux 则是敏感的。
如果我们的项目中有 ./src/I18n.ts,而我们在代码中写为 import I18n from './i18n' ,
一般情况下我们使用 windows 和 macOS 在本地开始时是不会暴露问题的,问题的暴露会被推迟到 ci linux 环境中构建时。
虽然发生频率不高,但一旦发生就得去定位解决。对于日常研发浪费时间事小,影响心情事大,而且如果运气不好,真发生在一些突发情况或需紧急上线时,就更尴尬了。
实践中,我们可能得益于一些工具,webpack CaseSensitivePathsPlugin 来提前暴露问题(实际上 webpack 现在也会直接给出 warning), 以减免实际造成的影响,但这并不影响此论点的本质
src/I18n.ts 重构为 src/I18n/index.ts 的情况,所以实际上文件名也必须遵循一样的规则至此,小结一下观点:
相对的,如果我们这么理解 “文件-模块-内容物” 这三层关系,笔者认为更简单且没有额外问题:
综上
即笔者认为 “使用 default export 是为了用文件名表示输出物” 这本身并不像直觉的那么好,有概念理解上的问题,也有些实际问题
上面说了一些偏观念上的问题,可能各有不同的感受,以下我们看看 default export 在实践层面的一些问题(直接对比 named export),应该更有说服力。
注,以下话题会基于 TypeScript 进行,对于 JavaScript 会少一些和 ts 特性相关的问题,但八九不离十
由于 default export 是匿名的,IDE 并不能通过 “名字” 来帮我们进行检索和自动补全
诚然,一些强大的 IDE 比如 WebStorm 即使是 default export 也能进行进行检索和自动补全,但那是终归是要耗费更多资源
对于 default export,每处 import 的地方实际上都进行了一次重命名。
这理论上相当于 “我们原则上放弃了保持命名的一致性”, 且不说拼写错误,这种小问题,我们总的来说不会希望项目中的同一个东西在一处叫做 createI18n() , 而在别处 new18n() 或 generateI18n()。
更重要的是,export default 的东西无法进行关于命名的重构。
而相对的, named export,则相当于“原则上贯彻项目范围内的一致且明确的命名”。 这使得关于“命名语义的渐进式重构” 得以轻松自然的进行。
简而言之,named export 可以使输出物在各模块间 “stay in touch” 保持实际的关联, 而 default export 只是向被 import 模块进行了值输送
我们的实践中其实不乏需要输出 “集合” 的情况, 比如 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}
.defaultdefault 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`);
}
// 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
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>(factory: () => Promise<{ default: T }>
): LazyExoticComponent {};
除去上文提及的一些笔者不赞同的 “对概念、认知上的惯性的顺从”,笔者认为 default export 并没有带来任何实际便利。 而 “可以直接重命名”、“不用写 {} ” ,在笔者看来,严格来说并不算是有效的研发便利。
最后,通过上文对 default export 的“数落”,一拉一踩,无非就想推荐 “我们应只使用 named export” 这么一个观点,这里总结一下好处:
文件即模块,模块名为文件名,遵循全小写 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')),}
];
(全文完)