frontend9 / fe9-library

九部知识库
1.94k stars 138 forks source link

前端组件库开发展示平台对比bisheng-vs-storybook #119

Open shaozj opened 5 years ago

shaozj commented 5 years ago

前端组件库开发展示平台对比bisheng-vs-storybook

组件开发的流程一般是需求确定、开发、测试、编译、发布。如果我们只是逐个地开发组件,每个组件独立发布,这样不利于沉淀出一个完整的组件库,形成一个完整的组件系统,以支撑某个领域的业务。开发组件库,除了需要在工程上完成开发、测试、编译和发布的支持,还需要有一个组件展示平台,能以网站的形式将所有组件的介绍、使用方式、使用示例、API 等展现出来。可以说,一个好的组件展示平台,对组件的使用和推广能起到很大的作用。
当前有两个组件开发展示平台是较为常见的,即 storybook 和 bisheng。storybook 自身定位为 ui 组件的开发环境,它提供了丰富的插件功能,可以交互式地开发和测试组件。bisheng 是蚂蚁金服前端开发的基于 markdown 和 react 生成静态网站的工具。虽然其定位是生成静态网站,不过主要用处还是用于生成组件展示平台。
下文将对这两个工具作为组件库开发展示平台进行技术上的对比和分析。

bisheng

路由生成

bisheng 入口文件

src/index.js 是 bisheng 程序的实际入口文件。start、build 等操作都是执行该文件中的函数。这个程序主要的功能是,读取 bisheng 配置文件 => 生成页面入口js文件 => 生成页面路由js文件 => 配置 webpack/webpackDevServer(开发环境) => 启动开发服务器 => webpack 编译完成后生成 html 文件。

页面入口文件

`entry.${configEntryName}.js`,根据模板 entry.nunjucks.jsx 生成入口文件于 tmp 目录下。下文中,我们默认将 configEntryName 设为 index。

路由生成

routes.nunjucks.jsx 为模板,传入 themePath, themeRoutes 等参数,在 tmp 目录下生成 routes.index.js 文件。这个文件将被入口文件引入,用于生成最终的页面路由:

// tmp/entry.index.js

var routes = require('./routes.index.js')(data);

其中,data 是我们处理后的有关页面的所有数据。
在生成 routes.index.js 时,我们会传入 themeRoutes,这个是我们在项目的 theme 配置文件中配置的路由。在 antd-fincore 中,配置 routes 如下:

// site/theme/index.js

const homeTmpl = "./template/Home/index";
const contentTmpl = "./template/Content/index";

  routes: {
    path: "/",
    component: "./template/Layout/index",
    indexRoute: {
      component: homeTmpl
    },
    childRoutes: [{
        path: "index-cn",
        component: homeTmpl
      },
      {
        path: "/docs/:children",
        component: contentTmpl
      },
      {
        path: "/components",
        component: contentTmpl
      },
      {
        path: "/components/:children",
        component: contentTmpl
      }
    ]
  }

theme 中配置的 routes 会在 routes.index.js 中经 getRoutes 函数做进一步处理,使之成为真正能被渲染出来的 routes。处理后返回的 routes 如下:

从上图可以看到,要渲染出路由对应的组件,最关键的是 getComponent 函数,这个 getComponent 函数将在后面分析。

Markdown 解析

webpackConfig.module.rules.push({
    test(filename) {
      return filename === path.join(bishengLib, 'utils', 'data.js') ||
        filename === path.join(bishengLib, 'utils', 'ssr-data.js');
    },
    loader: path.join(bishengLibLoaders, 'bisheng-data-loader'),
  });
// entry.nunjucks.jsx

const data = require('../lib/utils/data.js');

data.js 是个空文件,通过以上处理,其实是为了在 webpack 处理 data.js 时,加载 bisheng-data-loader

// components-AfcCodeEditor-demo.index.js

module.exports = {
    'basic': require('bisheng/lib/loaders/source-loader!antd-fincore/components/AfcCodeEditor/demo/basic.md'),
}

对所有 Markdown 文件处理后 bisheng-data-loader 返回如下形式的结果:

// bisheng-data-loader
// @return data

{
  markdown: {
    components: {
      AfcCodeEditor: {
        demo: function() {
          return new Promise(function(resolve) {
            require.ensure(
              [],
              function(require) {
                resolve(
                  require('tmp/components-AfcCodeEditor-demo.index.js')
                );
              },
              'components/AfcCodeEditor/demo'
            );
          });
        },
        index: function() {
          return new Promise(function(resolve) {
            require.ensure(
              [],
              function(require) {
                resolve(
                  require('bisheng/lib/loaders/source-loader!antd-fincore/components/AfcCodeEditor/index.md')
                );
              },
              'components/AfcCodeEditor/index.md'
            );
          });
        },
      },
    },
  },
  picked: { // 用于生成 menu
    components: [{ meta: [Object] }, { meta: [Object] }],
  },
  plugins: '[require("/bisheng-plugin-highlight/lib/browser.js"), {}]',
}

问题:如何将 props 传入route组件中的?

// bisheng-data-loader

collector(nextProps)
    .then((collectedValue) => {
      try {
        const Comp = Template.default || Template;
        Comp[dynamicPropsKey] = { ...nextProps, ...collectedValue };
        callback(null, Comp);
      } catch (e) { console.error(e) }
    })
    .catch((err) => {
      const Comp = NotFound.default || NotFound;
      Comp[dynamicPropsKey] = nextProps;
      callback(err === 404 ? null : err, Comp);
    });
// create-element.jsx

/* eslint-disable no-unused-vars */
const React = require('react');
/* eslint-enable no-unused-vars */
const NProgress = require('nprogress');

module.exports = function createElement(Component, props) {
  NProgress.done();
  const dynamicPropsKey = props.location.pathname;
  return <Component {...props} {...Component[dynamicPropsKey]} />;
};
// entry.index.js

const router = (
    <ReactRouter.Router
      history={ReactRouter.useRouterHistory(history.createHistory)({ basename })}
      routes={routes}
      createElement={createElement}
    />
  );
  ReactDOM.render(
    router,
    document.getElementById('react-content'),
  );

插件机制

插件主要分为 node 端的处理和 browser 端的处理。插件的 node 部分,主要是对 markdownData 做进一步的处理。

// utils/source-data.js

const markdown = require(transformer.use)(filename, fileContent);
const parsedMarkdown = plugins.reduce(
  (markdownData, plugin) =>
    require(plugin[0])(markdownData, plugin[1], isBuild === true),
  markdown,
);
return parsedMarkdown;

上个插件输出的结果是下个插件的输入,一个处理结果的例子如下:

{ 
  content: [],
  meta: 
   { order: 1,
     title: '搜索多个员工',
     filename: 'components/AfcUserSearch/demo/multiple.md',
     id: 'components-AfcUserSearch-demo-multiple' },
  toc: [ 'ul' ],
  highlightedCode: [],
}

上面的 meta 信息在页面渲染 menu 的时候会被用到。
插件的 browser 部分, 会放入 utils 中,会作为页面组件的属性传入页面中:

// routes.index.js

function generateUtils(data, props) {
  const plugins = data.plugins.map(pluginTupple => pluginTupple[0](pluginTupple[1], props));
  const converters = chain(plugin => plugin.converters || [], plugins);
  const utils = {
    get: exist.get,
    toReactComponent(jsonml) {
      return toReactElement(jsonml, converters);
    },
  };
  plugins.map(plugin => plugin.utils || {})
    .forEach(u => Object.assign(utils, u));
  return utils;
}

插件 browser 部分的 converters 和 utils 分别传入 toReactElement 和 utils 中。最终 utils 将被传入页面 react 组件中,在渲染页面时可以调用使用。例如:

// antd-fincore
// site/theme/template/Content/ComponentDoc.jsx

{props.utils.toReactComponent(
  ['section', { className: 'markdown' }].concat(getChildren(content))
)}

bisheng 小结

stroybook

storybook 本身支持 react、vue、angular 等多种框架,我们主要分析 react 框架。storybook-react,react 部分分为 client 和 server 两部分。其依赖于 storybook-core,core 分为 server 和 client 两部分。

工具入口

开发环境,启动开发服务器实际入口文件为 app/react/src/server/index.js。启动开发服务器的代码十分简洁,调用 core 的 buildDev 函数,传入相应配置:

// app/react/src/server/index.js

import { buildDev } from '@storybook/core/server';
import options from './options';

buildDev(options);

在 core 的 buildDev 函数中,启动一个 express 服务器,使用 storybook middleware:

// core/src/server/build-dev.js

const storybookMiddleware = await storybook(options);

app.use(storybookMiddleware);

在 storybook middleware 中,用 getBaseConfig,自定义的 .babelrc 文件和 webpack.config.js 配置文件生成最终的 webpackConfig。new 一个 express Router,router 使用 webpackDevMiddlewareInstancewebpackHotMiddleware。配置相应的路由。

// lib/core/src/server/middleware.js

router.use(webpackDevMiddlewareInstance);
router.use(webpackHotMiddleware(compiler));

...

router.get('/', (req, res) => {
  res.set('Content-Type', 'text/html');
  res.sendFile(path.join(`${__dirname}/public/index.html`));
});

router.get('/iframe.html', (req, res) => {
  res.set('Content-Type', 'text/html');
  res.sendFile(path.join(`${__dirname}/public/iframe.html`));
});

整个流程就是启动一个 express 服务器,添加 webpackDevMiddleware 和 webpackHotMiddleware,其中需要 webpackConfig,这个 webpackConfig 是用 storybook 中的默认配置和用户自定义配置结合生成的。

页面入口

storybook 的页面结构是外层为框架页面 manager,内部为 iframe。查看 webpackConfig 中的 entry 配置:

manager 的入口为 storybook 框架中写的 lib/core/src/client/manager/index.js

// lib/core/src/client/manager/index.js

import { document } from 'global';
import renderStorybookUI from '@storybook/ui';
import Provider from './provider';

const rootEl = document.getElementById('root');
renderStorybookUI(rootEl, new Provider());

manager 页面的渲染基于 @storybook/mantra-core 框架,状态管理用了 @storybook/podda,这些我们都不熟悉,暂时只要理解基于他们来渲染外层框架页面 manager。

iframe 页面的入口为用户项目下 storybook 配置目录下的 config.js 文件。在我创建的工程中,其配置如下:

// .storybook/config.js

import { configure, addDecorator } from '@storybook/react';
import { withOptions } from '@storybook/addon-options';

addDecorator(
  withOptions({
    hierarchySeparator: /\/|\./,
    hierarchyRootSeparator: /\|/,
  })
);

function loadStories() {
  require('../stories/');
}

configure(loadStories, module);

可见,configure 函数是渲染 iframe 页面的入口函数。

下图展示了 storybook 整个页面的架构:

路由生成和页面渲染

子页面加载所有的 stories,做处理,生成目录结构数据,通知主页面渲染:

// lib/core/src/client/preview/config_api.js

_renderMain(loaders) {
    if (loaders) loaders();

    const stories = this._storyStore.dumpStoryBook();
    // send to the parent frame.
    this._channel.emit(Events.SET_STORIES, { stories });

    // clear the error if exists.
    this._reduxStore.dispatch(clearError());
    this._reduxStore.dispatch(setInitialStory(stories));
}

这个 stories 是如何得到的呢?在 _renderMain 中,首先执行了 loaders(); 也就是我们在 config.js 中配置的 require('../stories/');。这样其实也就会执行我们所有编写的 stories。每个 story 编写的格式都是如下所示:

// stories/AvatarList/index.js

import React from 'react';
import { storiesOf } from '@storybook/react';

storiesOf('AvatarList', module)
  .add('example1', () => {
    return (
      <div>
        AvatarList
      </div>
    );
  });

这里关键的是 storiesOf 这个函数。

// lib/core/src/client/preview/client_api.js

storiesOf = (kind, m) => {
    ...

    const localDecorators = [];
    let localParameters = {};
    const api = {
      kind,
    };

    // apply addons
    Object.keys(this._addons).forEach(name => {
      const addon = this._addons[name];
      api[name] = (...args) => {
        addon.apply(api, args);
        return api;
      };
    });

    api.add = (storyName, getStory, parameters) => {
      if (typeof storyName !== 'string') {
        throw new Error(`Invalid or missing storyName provided for a "${kind}" story.`);
      }

      if (this._storyStore.hasStory(kind, storyName)) {
        logger.warn(`Story of "${kind}" named "${storyName}" already exists`);
      }

      // Wrap the getStory function with each decorator. The first
      // decorator will wrap the story function. The second will
      // wrap the first decorator and so on.
      const decorators = [...localDecorators, ...this._globalDecorators, withSubscriptionTracking];

      const fileName = m ? m.id : null;

      const allParam = { fileName };

      [this._globalParameters, localParameters, parameters].forEach(params => {
        if (params) {
          Object.keys(params).forEach(key => {
            if (Array.isArray(params[key])) {
              allParam[key] = params[key];
            } else if (typeof params[key] === 'object') {
              allParam[key] = { ...allParam[key], ...params[key] };
            } else {
              allParam[key] = params[key];
            }
          });
        }
      });

      // Add the fully decorated getStory function.
      this._storyStore.addStory(
        kind,
        storyName,
        this._decorateStory(getStory, decorators),
        allParam
      );
      return api;
    };

    api.addDecorator = decorator => {
      localDecorators.push(decorator);
      return api;
    };

    api.addParameters = parameters => {
      localParameters = { ...localParameters, ...parameters };
      return api;
    };

    return api;
  };

它的主要工作是应用 addons,应用 decorators 然后将处理后的 story 添加入 storyStore 中。之后,storybook 会调用 render 函数,利用之前存入 storyStore 中的数据,渲染出当前 story 的 iframe 页面。

主页面和子页面之间的通信

通过 channel 通信,channel 基于 post-message 实现。

// lib/core/src/client/manager/provider.js

...

handleAPI(api) {
    api.onStory((kind, story) => {
      this.channel.emit(Events.SET_CURRENT_STORY, { kind, story });
    });
    this.channel.on(Events.SET_STORIES, data => {
      api.setStories(data.stories);
    });
    this.channel.on(Events.SELECT_STORY, data => {
      api.selectStory(data.kind, data.story);
    });
    this.channel.on(Events.APPLY_SHORTCUT, data => {
      api.handleShortcut(data.event);
    });
    addons.loadAddons(api);
}

webpack 配置

storybook 的默认配置是基于 create-react-app 的。用户可以自定义修改 webpack 配置。如果我们是基于 bigfish 的项目,那么可以按如下方式来修改 webpack 配置:

// .storybook/webpack.config.js

const path = require('path');

module.exports = (baseConfig, env, defaultConfig) => {
  defaultConfig.module.rules.push({
    test: /\.jsx?$/,
    include: [path.resolve(__dirname, '../src'), path.resolve(__dirname, '../stories')],
    exclude: /node_modules/,
    use: [
      {
        loader: 'babel-loader',
        options: {
          cacheDirectory: true,
          babelrc: false,
          presets: [
            [
              '@babel/env',
              {
                targets: {
                  browsers: ['Chrome>=59'],
                },
                modules: false,
                loose: true,
              },
            ],
            '@babel/react',
          ],
          plugins: [
            [
              'import',
              { libraryName: '@alipay/bigfish/antd', libraryDirectory: 'es', style: true },
            ],
            [require("@babel/plugin-proposal-class-properties"), { "loose": false }],
            [require("@babel/plugin-proposal-decorators"), { "legacy": true }],
          ],
        },
      },
    ],
  });

  defaultConfig.module.rules.push({
    test: /\.less$/,
    include: [path.resolve(__dirname, '../src'), path.resolve(__dirname, '../stories')],
    use: [
      'style-loader',
      {
        loader: 'css-loader',
        options: {
          importLoaders: 1,
          sourceMap: false,
          modules: true,
          localIdentName: '[local]___[hash:base64:5]',
        },
      },
      {
        loader: 'postcss-loader',
        options: {
          plugins: () => [require('autoprefixer')],
        },
      },
      {
        loader: 'less-loader',
        options: {
          modifyVars: {},
          javascriptEnabled: true,
        },
      },
    ],
  });

  defaultConfig.module.rules.push({
    test: /\.less$/,
    include: path.resolve(__dirname, '../node_modules'),
    use: [
      'style-loader',
      'css-loader',
      {
        loader: 'less-loader',
        options: {
          modifyVars: {},
          javascriptEnabled: true,
        },
      },
    ],
  });

  defaultConfig.resolve.alias = defaultConfig.resolve.alias || {};
  defaultConfig.resolve.alias['@alipay/bigfish/react'] = 'react';
  defaultConfig.resolve.alias['@alipay/bigfish/antd'] = 'antd';
  defaultConfig.resolve.alias['@alipay/bigfish/util/prop-types'] = 'prop-types';
  defaultConfig.resolve.alias['@alipay/bigfish/util/classnames'] = 'classnames';
  defaultConfig.resolve.alias['@alipay/bigfish/sdk/fetch'] = 'isomorphic-fetch';
  defaultConfig.resolve.alias['@alipay/bigfish/sdk/router'] = 'react-router-dom';
  defaultConfig.resolve.alias['@alipay/bigfish/sdk/history'] = 'history';
  defaultConfig.resolve.alias['~'] = path.resolve(__dirname, '../src');

  return defaultConfig;
};

这里我们采用了 full-control mode,完全使用我们返回的修改了的 webpackConfig;

markdown 处理

在 storybook 的 webpack 配置中,默认给 markdown 文本有如下配置 :

{
  test: /\.md$/,
  use: [
    {
      loader: require.resolve('raw-loader'),
    },
  ],
},

用 raw-loader 直接加载 markdown 文件,也就是直接得到了字符串。为了构建我们自己的组件库,我们需要将 markdown 字符串渲染为页面元素,同时,我们需要模仿 antd 插入组件示例和源码展示:

// stories/AvatarList/index.js

import React from 'react';
import { storiesOf } from '@storybook/react';
import CodeExample from '../CodeExample';
import Demo1 from './demo1';
import Demo1Raw from '!raw-loader!./demo1';
import MarkView from '../MarkView';
import readme from '~/component/AvatarList/index.zh-CN.md';

storiesOf('AvatarList', module)
  .add('avatar list', () => {
    return (
      <MarkView readme={readme} name="AvatarList">
        <CodeExample title="基本用法" code={Demo1Raw}>
          <Demo1 />
        </CodeExample>
      </MarkView>
    );
  });

得到如下的效果:

对 markdown 的处理,完全放在 browser 端,通过 MarkView 组件来处理。这里只做了最简单的处理,将 Markdown 字符串解析为 ast,从 ast 中取出 yaml 用于渲染标题和子标题,将 ast 分为两部分,头部是组件介绍,尾部是 API 文档,中间插入我们编写的各个 demo 示例。

插件机制

bigfish 的插件可以分为两种,Decorators 和 Addons。其实在上文分析 storiesOf 函数时,已经讲到了 Decorators 和 Addons。

Decorators

// lib/core/src/client/preview/client_api.js

export const defaultDecorateStory = (getStory, decorators) =>
  decorators.reduce(
    (decorated, decorator) => context => decorator(() => decorated(context), context),
    getStory
  );

const decorators = [...localDecorators, ...this._globalDecorators, withSubscriptionTracking];

收集完所有的 decorators 后,逐个执行 decorators,第一个 decorator 会包裹原始 story,第二个会包裹第一个,以此类推。

例子,让 story 居中:

import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';

import Button from './button';

const styles = {
  textAlign: 'center',
};
const CenterDecorator = (storyFn) => (
  <div style={styles}>
    { storyFn() }
  </div>
);

storiesOf('Button', module)
  .addDecorator(CenterDecorator)
  .add('with text', () => (
    <Button onClick={action('clicked')}>Hello Button</Button>
  ))
  .add('with some emojies', () => (
    <Button onClick={action('clicked')}><span role="img" aria-label="so cool">😀 😎 👍 💯</span></Button>
  ));

Addons

Addons 更为强大,除了包裹 story 的处理,Addons 还提供其他特性的支持。

总结

shaozj commented 5 years ago

github 项目地址:https://github.com/shaozj/stories