[Web翻译]Web Components版Storybook进阶

2,030 阅读14分钟

原文地址:dev.to/open-wc/sto…

原文作者:dev.to/dakmor

发布时间:2019年11月30日 ・13分钟阅读

构建一个Web应用程序是一个相当大和具有挑战性的任务。 与许多大任务一样,将它们分解成更小的部分是有意义的。 对于应用程序来说,这通常意味着将你的应用程序分割成多个独立的组件。

一旦你开始这样做,你会注意到你手中有很多单独的部件,而且要对所有这些移动的部件进行概述是很困难的。

为了解决这个问题,我们从相当长的时间开始就一直在推荐storybook

它对web组件的支持一直很好(通过@storybook/polymer),而最近增加的@storybook/web-components让它变得更好。

然而,storybook中的一些部分并没有针对开发web组件进行微调(open-wc方式)。

让我们来看看其中的一些点,以及如何改进它们。

你可以在随附的github repo中跟读一下

经过一个典型的storybook设置,它看起来像这样

$ start-storybook
info @storybook/web-components v5.3.0-alpha.40
info
info => Loading presets
info => Loading presets
info => Loading custom manager config.
info => Using default Webpack setup.
webpack built b6c5b0bf4e5f02d4df8c in 7853ms
╭───────────────────────────────────────────────────╮
│                                                   │
│   Storybook 5.3.0-alpha.40 started                │
│   8.99 s for manager and 8.53 s for preview       │
│                                                   │
│    Local:            http://localhost:52796/      │
│    On your network:  http://192.168.1.5:52796/    │
│                                                   │
╰───────────────────────────────────────────────────╯
# browser opens

当我们把它与使用npm init @open-wc启动项目进行比较时

$ npm run start
es-dev-server started on http://localhost:8000
  Serving files from '/my-demo'.
  Opening browser on '/my-demo/'
  Using history API fallback, redirecting non-file requests to '/my-demo/index.html'
# browser opens

最明显的区别是,在一种情况下,我们有2个约8秒的构建,而在另一种情况下,我们没有任何构建。

那么为什么会有2次构建呢?

为了了解为什么会需要这样做,我们首先需要了解像storybook这样的通用演示系统的一些要求。

通用演示系统的游览

假设我们是一家创业公司,我们正在创建一个新的应用程序。 我们选择的技术是Vue.js。我们愉快地开始构建我们的应用程序,很快我们看到需要有一个演示系统来展示和工作在所有这些单独的组件。他们说,去吧,我们为Vue建立了一个演示系统。

它可能看起来像这样

只是一些示例代码--不要把它看成是好的vue代码😅。

<template>
  <div class="hello">
    <h1>{{ msg }}</h1>
    <ul>
      <li v-for="demo in demos" v-on:click="showDemo(demo.name)">{{demo.name}}</li>
    </ul>

    <div v-html="demo"></div>
  </div>
</template>

<script>
  export default {
    name: 'HelloWorld',
    props: {
      msg: {
        type: String,
        default: 'My Demo System',
      },
      demos: {
        type: Array,
        default: () => [
          { name: 'Demo One', content: '<h1>Hey there from demo one</h1>' },
          { name: 'Demo Two', content: '<h1>I am demo two</h1>' },
        ],
      },
    },
    methods: {
      showDemo: function(name) {
        this.demoIndex = this.demos.findIndex(el => el.name === name);
      },
    },
    data() {
      return {
        demoIndex: -1,
      };
    },
    computed: {
      demo() {
        if (this.demoIndex >= 0) {
          return this.demos[this.demoIndex].content;
        }
        return '<h1>Please select a demo by clicking in the menu</h1>';
      },
    },
  };
</script>

这里的代码只显示最相关的信息

关于演示和更多细节,请查看vue-demo-system文件夹。

你可以通过npm i &&npm run serve来启动它。

一切都能正常工作,每个人都很开心--生活很美好。

快进12个月,我们有了一个新的CIO。一股新风吹来,随之而来的是一个繁荣的机会,我们要开发第二个应用。然而,这股风要求这次要用Angular写。没问题--我们是专业的,于是我们开始了新应用的开发工作。

很早的时候,我们就看到了和之前类似的模式--到处都是组件,我们需要一种方法来单独工作和演示它们。

啊,我们认为这很容易,我们已经有了一个系统😬。

我们已经尽了最大努力--但是angular组件就是不想和vue演示程序很好的配合😭。

我们能做什么呢?我们现在真的需要重新创建Angular的演示系统吗?

似乎我们的问题是,将演示UI和组件演示放在同一个页面上,有一个不必要的副作用,那就是我们只能在demo中使用UI系统。

不是很通用那是😅

我们能不能把UI和演示分开?

使用iframes并只通过postMessage进行通信如何? 那是不是意味着每个窗口都可以做自己想做的事情?🤞

让我们来做一个简单的POC(概念证明),用的是

  • 一个ul/li列表作为一个菜单
  • 显示演示的iframe

我们需要什么?

  1. 我们从一个空菜单开始
  2. 我们听取了演示的帖子信息
  3. iframe会被加载,里面的demo会发射帖子消息。
  4. 然后我们为每个演示创建菜单项目
  5. 在点击菜单项时,我们改变iframe的网址。
  6. 如果iframe得到一个demo来显示,它就会更新html。

以下是index.html

<ul id="menu"></ul>
<iframe id="iframe" src="./iframe.html"></iframe>

<script>
  window.addEventListener('message', ev => {
    const li = document.createElement('li');
    li.addEventListener('click', ev => {
      iframe.src = `./iframe.html?slug=${slug}`;
    });
    menu.appendChild(li);
  });
</script>

这里是iframe.html

<body>
  <h1>Please select a demo by clicking in the menu</h1>
</body>

<script>
  // Demo One
  if (window.location.href.indexOf('demo-one') !== -1) {
    document.body.innerHTML = '<h1>Hey there from demo two</h1>';
  }
  // Demo Two
  if (window.location.href.indexOf('demo-two') !== -1) {
    document.body.innerHTML = '<h1>I am demo two</h1>';
  }

  // register demos when not currently showing a demo
  if (window.location.href.indexOf('slug') === -1) {
    parent.postMessage({ name: 'Demo One', slug: 'demo-one' });
    parent.postMessage({ name: 'Demo Two', slug: 'demo-two' });
  }
</script>

这里的代码只显示最相关的信息

关于演示和更多细节,请看postMessage文件夹。

你可以通过npm i &&npm run start来启动它。

现在想象一下,UI远远不止是一个ul/li列表,而demo也遵循一定的demo格式?

这会不会是一个可以让UI和Demo用完全不同的技术编写的系统呢?

答案是YES💪

唯一的通信方式是通过postMessages完成的。

因此,预览只需要知道使用哪种postMessage格式即可。

另外,postMessage是一个本地函数,所以每个框架或系统都可以使用它们。

两个构建(待续)

上述概念就是storybook所使用的--这意味着实际上有2个应用程序正在运行。 一个是storybook UI(称为管理器),一个是你的实际演示(称为预览)。 知道了这一点,那么有2个独立的构建也就说得通了。

但为什么会有一个构建步骤呢?为什么storybook会有这样的设置?

让我们看看需要什么才能让一些代码在多个浏览器中运行和工作。

基于浏览器功能的应用发布的游览

让我们举个小例子,我们使用的是私有类字段

这个功能目前处于第3阶段,只在Chrome浏览器中可用。

// index.js
import { MyClass } from './MyClass.js';

const inst = new MyClass();
inst.publicMethod();

// MyClass.js
export class MyClass {
  #privateField = 'My Class with a private field';

  publicMethod() {
    document.body.innerHTML = this.#privateField;
    debugger;
  }
}

我们特意在里面放了一个调试器断点,以查看浏览器执行的实际代码。

让我们看看webpack与一些babel插件是如何处理的。(见完整配置)

__webpack_require__.r(__webpack_exports__);
/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "MyClass", function() { return MyClass; });
function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
function _defineProperties(target, props) {
// ... more helper functions

var MyClass =
/*#__PURE__*/
function () {
  function MyClass() {
    _classCallCheck(this, MyClass);

    _privateField.set(this, {
      writable: true,
      value: 'My Class with a private field'
    });
  }

  _createClass(MyClass, [{
    key: "publicMethod",
    value: function publicMethod() {
      document.body.innerHTML = _classPrivateFieldGet(this, _privateField);
      debugger;
    }
  }]);

  return MyClass;
}();

var _privateField = new WeakMap();

哇,这是相当多的代码🙈,它看起来并不是真的像写😱的代码。

注意:在大多数情况下,你不会看到这一点,因为源地图

在一个典型的webpack和babel设置中,您的代码会被编译到es5,以便能够在IE11等旧的浏览器上运行代码。

然而,您可能会问,我有多常在旧的浏览器上运行我的应用程序?

一个典型的开发者可能应该在现代浏览器上开发约90%,在旧浏览器上开发约10%,以确保一切仍能正常运行。 至少我们希望你有这么好的工作流程🤗。

那么问题是,如果只需要10%的代码,为什么要100%地编译、出货、调试和使用这些 "奇怪 "的代码呢? 我们能不能做得更好?

让我们通过在chrome上打开同样的文件来看看es-dev-server是如何处理的。

export class MyClass {
  #privateField = 'My Class with a private field';

  publicMethod() {
    document.body.innerHTML = this.#privateField;
    debugger;
  }
}

它看起来和原始代码一模一样--因为它就是。原样的代码完全可以在chrome中运行,无需任何调整。 这就是发生的事情,它的源码是原封不动的。

然而,我们使用的是私有类字段,这是一个不支持的功能,例如在Firefox上。 如果我们在那里打开它会发生什么?

它失败了😭

语法错误:目前不支持私有字段。

好吧,这是我们的错,因为我们使用的是第三阶段的功能,现在没有进行任何编译。

让我们用es-dev-server --babel试试,它将和webpack一样使用.babelrc

下面的代码将被生成。

function _classPrivateFieldGet(receiver, privateMap) {
  var descriptor = privateMap.get(receiver);
  if (!descriptor) {
    throw new TypeError('attempted to get private field on non-instance');
  }
  if (descriptor.get) {
    return descriptor.get.call(receiver);
  }
  return descriptor.value;
}

export class MyClass {
  constructor() {
    _privateField.set(this, {
      writable: true,
      value: 'My Class with a private field',
    });
  }

  publicMethod() {
    document.body.innerHTML = _classPrivateFieldGet(this, _privateField);
    debugger;
  }
}

var _privateField = new WeakMap();

它的工作💪 它只编译私有字段,而不是所有的字段👌。

但是,如果你现在回到chrome,你会发现它现在也是在那里编译的,原因是一旦你开始通过babel,它就会根据@babel/reset-env来做,而babel总是保守的。 原因是一旦你开始通过babel,它就会根据@babel/reset-env来做它的事情,而babel总是偏向保守。

真正的神奇✨发生在你在IE11这样的旧浏览器上打开它的时候。 因为那时它会编译成systemjs,一个es模块的polyfill。

它将看起来像这样

System.register([], function(_export, _context)) {
  "use strict";

  var MyClass, _privateField;

  function _classCallback(instance, Constructor) {
// ...

它的行为和真正的es模块完全一样,这样你的代码就可以在不支持它们的浏览器上正常工作💪。

如果您担心速度问题,最好只使用第4阶段的功能,完全不用babel。 如果真的需要,您可以使用2个启动命令

"start": "es-dev-server --open",
"start:babel""es-dev-server --babel --open",

所以,es-dev-server自动模式实现的是,你不需要考虑它。 在现代浏览器上,它将是即时的,甚至在你需要在旧的浏览器上进行测试的时刻,它也可以工作。

总结一下,为了能够在所有我们想要支持的浏览器中工作和调试代码,我们基本上有2个选择。

  1. 编译到最低分母
  2. 基于浏览器功能的服务代码

和往常一样,请不要疯狂地使用新功能。 请使用您的开发浏览器上当前稳定的功能。 当您不使用自定义的babel配置时,您将获得最好的体验。

这里的代码只显示最相关的信息 关于演示和更多细节,请看EsDevServer-vs-WebpackDevServer文件夹。 您可以通过npm run startnpm run start:babelnpm run webpack来启动它。

源地图

幸运的是,在大多数情况下,即使在使用编译后的代码,你也会看到源代码。 这怎么可能呢?这都要归功于Sourcemaps

它们是一种将原始代码映射到编译代码的方式,浏览器足够聪明,可以将它们链接在一起,只向你展示你感兴趣的内容。 只要在你的开发工具中勾选 "启用JavaScript源码图 "这个选项就可以了。

它真的很厉害,就是能用。然而,它是另一个移动的部分,可能会打破或你需要知道它至少。

机会

所以,从现代代码的编译和发布来看,我们看到了一个机会之窗。 我们希望拥有storybook的功能,但同时也希望不依赖webpack的易用性。

简而言之,我们的想法是把storybook ui和es-dev-server结合起来。

让我们开始吧💪

这里是总体规划

  1. 预先建立storybook ui (所以我们不需要被迫使用webpack)
  2. 替换掉webpack的魔法,比如require.context
  3. 模仿预览与经理的沟通方式。
  4. 使用rollup来构建静态版本的storybook。

Storybook进阶

预制故事书

为了获得es模块版本的故事书预览,需要通过webpack & rollup。 是的,这是一个小黑魔法,但这是唯一可行的方法。 看来storybook还没有优化到有一个完全分离的管理器/预览。 但是,嘿嘿,它的工作,我们将与storybook合作,使这个更好💪。

你可以在github上找到源码,输出的内容在npm上发布为@open-wc/storybook-prebuilt

Prebuilt有以下好处。

  • 速度快
  • 预览可以独立于故事书的构建设置

预制有以下缺点。

  • 你不能改变预制的附加组件。
  • 然而,你可以创建你自己的预制的

替换掉webpack魔法

在当前的故事书中,preview.js中使用require.context来定义哪些故事被加载。 然而,这是一个只有在webpack中才有的功能,基本上意味着它是对特定构建工具的锁定。 我们希望能够自由地选择我们想要的任何东西,所以这需要被取代。

我们选择了一个命令行参数。

简而言之,你现在不需要在你的js中定义寻找故事的位置,而是在命令行中通过

start-storybook --stories 'path/to/stories/*.stories.{js,mdx}'

这样做可以将这个值暴露给各种工具,如koa-middlewaresrollup

模仿预览与管理器的通信方式。

现在我们可以 "包含/使用 "独立的storybook UI(管理器)了,现在是时候旋转es-dev-server了。

对于管理器,我们创建了一个index.html,它可以归结为一个单一的导入。

<script src="path/to/node_modules/@open-wc/storybook-prebuilt/dist/manager.js"></script>

我们做了一些特殊的缓存,以确保您的浏览器只加载一次故事书管理器。

对于预览来说,我们需要加载/注册所有单独的故事,如postMessage示例所示。 我们将通过命令行参数获得故事列表。

浏览器最终使用的重要部分是动态导入所有故事文件,然后调用storybooks configure来触发postMessage。

import { configure } from './node_modules/@open-wc/demoing-storybook/index.js';

Promise.all([
  import('/stories/demo-wc-card.stories.mdx'),
  // here an import to every story file will created
]).then(stories => {
  configure(() => stories, {});
});

额外的mdx支持

即将推出的故事书5.3.x(目前处于测试阶段)将引入文档模式。 这是一种特殊的模式,可以将markdown和故事写在一个文件中,并在一个页面上显示。 你可以把它看成是Markdown,但是是立体化的😬。

这种格式被称为mdx,允许编写markdown,也允许导入javascript和编写jsx。

我们推荐它作为编写组件文档的主要方式。

为了支持这样的功能,es-dev-server需要了解如何处理mdx文件。

为此,我们添加了一个koa中间件,它可以将对*.mdx文件的请求转换为CSF(Component Story Format)。

这基本上意味着当你请求http://localhost:8001/stories/demo-wc-card.stories.mdx,文件系统中的文件看起来是这样的。

###### Header

<Story name="Custom Header">
  {html`
    <demo-wc-card header="Harry Potter">A character that is part of a book series...</demo-wc-card>
  `}
</Story>

它将会把这些信息发送到您的浏览器上

// ...
mdx('h6', null, `Header`);
// ...
export const customHeader = () => html`
  <demo-wc-card header="Harry Potter">A character that is part of a book series...</demo-wc-card>
`;
customHeader.story = {};
customHeader.story.name = 'Custom Header';
customHeader.story.parameters = {
  mdxSource:
    'html`\n    <demo-wc-card header="Harry Potter">A character that is part of a book series...</demo-wc-card>\n  `',
};

你可以打开你的网络面板,看一下响应💪

使用rollup来建立一个静态的故事书

在大多数情况下,你还会希望在静态服务器上的某个地方发布你的故事书。 为此,我们预先设置了一个滚动配置,它可以实现上述所有功能并输出两个版本。

  1. 对于支持es模块的现代浏览器和
  2. 对于所有其他的浏览器,我们提供了一个es5版本,其中包含所有的polyfills。

关于不同版本如何从静态服务器发货的更多细节,请参见open-wc rollup推荐

这里的代码只显示了最相关的信息 完整的演示请看storybookOnSteroids文件夹。 你可以通过npm i &&npm run storybook来启动它。 实际的源代码请看@open-wc/demoing-storybook

判决书

我们做到了💪

一个功能齐全的演示系统,可以

  • 在现代的浏览器上是没有内置的
  • 闪电启动
  • 有一个预制的用户界面
  • 根据浏览器功能提供预览代码
  • 使用es-dev-server,所以你可以使用它的所有功能。

最重要的是,看到一个完全独立的服务器可以为故事书提供动力,实在是太好了。 故事书的设置真的很值得👍。

PS:这并不是所有的玫瑰和彩虹,但有了这一步,我们现在知道这是可能的--进一步的改进,比如一个较小的预览包或单独的mdx转换包,将在某些时候发生🤗。

未来

我们希望能以此为起点,让storybook也能直接支持其他框架服务器👍。 即使是非JavaScript服务器也是可以的--Ruby、PHP你准备好了吗?🤗

如果您有兴趣支持您的框架服务器,并且您需要帮助/指导,请务必让我们知道。

鸣谢

请在Twitter上关注我们,或者在我的个人Twitter上关注我。 请务必查看我们在open-wc.org上的其他工具和推荐。

感谢BennyLars的反馈,并帮助我把我的涂鸦变成了关注ab

感谢BennyLars的反馈,并帮助我把我的涂鸦变成了一个可遵循的故事。

封面照片由Nong Vang on Unsplash提供。


通过www.DeepL.com/Translator(免费版)翻译