Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
shaozj opened this issue Oct 27, 2018 · 1 comment
Open

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

shaozj opened this issue Oct 27, 2018 · 1 comment

Comments

@shaozj
Copy link
Owner

shaozj commented Oct 27, 2018

前端组件库开发展示平台对比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 解析

  • config/updateWebpackConfig 函数中,添加了如下配置
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
// entry.nunjucks.jsx

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

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

  • bisheng-data-loader 根据 config 中的配置,加载到所有 markdown 文件,在 tmp 目录下为每个 markdown demo 文件生成一个对应的 js 文件,用于异步加载,其形式如下:
// 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"), {}]',
}
  • 将上述数据 data 传入 routes.index.js,在 routes.index.js 中的 getRoutes 函数中,将生成页面的路由。路由对应的组件通过用户在 theme 中的配置取到模板,然后将模板和 data 数据结合最终渲染出页面。
    • data 中 markdown 相关的数据,最初只是一个require md 文件的 Promise。需要经 source-loader 处理 markdown 文件,其中,用 mark-twain 将 markdown 转换为 JsonML。
    • routes.index.js 中的 collector 函数中将 utils(包括 plugins 如 jsonml-to-react-element)传入到浏览器端的组件中(通过 props),在各个页面中,通过调用 props.utils.toReactComponent 将 JsonML 转换为 react element,渲染出页面。在 antd-fincore 中,经 collector 函数处理后的数据示例如下:

问题:如何将 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 小结

  • bisheng 的处理主要分为两个部分,node 和 browser。在 node 层,主要处理 webpack 配置,webpack-dev-server,生成入口文件、路由文件、各个页面对应路径文件、html 文件等。将 md 文件转换为 jsonML 的处理主要是在 data-loader 中。在 browser 层则是主要处理页面实际渲染相关的部分,渲染出路由、将 jsonML 转换为 react-element 等。在两部分的配合下,最终渲染出类似于 antd 官网这样的网站。

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 还提供其他特性的支持。

  • Add a panel to Storybook (like Action Logger).
  • Interact with the story and the panel.
  • Set and get URL query params.
  • Select a story.
  • Register keyboard shortcuts (coming soon).

总结

  • 在 bisheng 中,我们甚至能看到一些我们现在使用的框架的影子。例如在 umijs 中,将路由的处理集成到了框架中,而非完全交给开发者处理。以及提供了插件机制。这也说明了技术之间有很多相通之处
  • 目前各种工具、框架,基本都是基于 webpack 进行开发,可以看到,webpack 基本已经是目前前端工程化、工具、框架开发的基石。很多库和框架都是基于 webpack 做很多外围都工作。
  • 基于 storybook 实现组件库,组件展示页面的渲染都在 browser 端,相对来说更为清晰一些,也不需要从 node 层做数据处理再传入 browser 层处理。同时 demo 的编写可以完全采用 js 而不是写在 markdown 中,这对于测试、debug 来说要方便很多。
  • storybook 的实现更为清晰,代码质量很高,文档详细,目前 star 数已过三万,而 bisheng 目前已无人维护,所以我们可以更多尝试下 storybook。不过 storybook 本身并不对 markdown 做处理,例如转换为 jsonml 的处理,所以这块需要我们编写插件或者自己在项目中实现。
@Eating-Eating
Copy link

手动点赞

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants