本文共 13522 字,大约阅读时间需要 45 分钟。
在上个月举行的GraphQL峰会上,我做了一场演讲,其中涉及很多实时编码演示,可以看一下视频回顾:
从参会者的反馈来看,人们非常惊讶我们的开发速度为什么会如此之快,但因为我没有太多时间解释其中的原理,很多人认为这是因为Airbnb投入了数年的工程师时间构建了可以支持GraphQL的基础设施。但实际上,演示中有90%的繁重工作都是由Apollo的CLI工具提供支持的。
在这篇文章中,我将通过部分代码介绍这种快速的开发体验。
在演讲中,我们假定开发了一个系统,这个系统有一个动态页面,这个页面基于一个可以返回一系列“section”的查询,这些section是响应式的,用于定义页面UI。
主文件是一个生成文件(稍后我们将介绍如何生成它),如下所示:
import SECTION_TYPES from '../../apps/PdpFramework/constants/SectionTypes';import TripDesignerBio from './sections/TripDesignerBio';import SingleMedia from './sections/SingleMedia';import TwoMediaWithLinkButton from './sections/TwoMediaWithLinkButton';// …many other imports…const SECTION_MAPPING = { [SECTION_TYPES.TRIP_DESIGNER_BIO]: TripDesignerBio, [SECTION_TYPES.SINGLE_MEDIA]: SingleMedia, [SECTION_TYPES.TWO_PARAGRAPH_TWO_MEDIA]: TwoParagraphTwoMedia, // …many other items…};const fragments = { sections: gql` fragment JourneyEditorialContent on Journey { editorialContent { ...TripDesignerBioFields ...SingleMediaFields ...TwoMediaWithLinkButtonFields # …many other fragments… } } ${TripDesignerBio.fragments.fields} ${SingleMedia.fragments.fields} ${TwoMediaWithLinkButton.fragments.fields} # …many other fragment fields…`,};export default function Sections({ editorialContent }: $TSFixMe) { if (editorialContent === null) { return null; } return ( \u0026lt;React.Fragment\u0026gt; {editorialContent.map((section: $TSFixMe, i: $TSFixMe) =\u0026gt; { if (section === null) { return null; } const Component = SECTION_MAPPING[section.__typename]; if (!Component) { return null; } return \u0026lt;Component key={i} {...section} /\u0026gt;; })} \u0026lt;/React.Fragment\u0026gt; );}Sections.fragments = fragments;
因为section可能会有很多(现在用于搜索的section大概有50个),所以我们没有需要事先将所有可能的section都打包。
每个section组件都定义了自己的查询片段,与section的组件代码放在一起:
import { TripDesignerBioFields } from './__generated__/TripDesignerBioFields';const AVATAR_SIZE_PX = 107;const fragments = { fields: gql` fragment TripDesignerBioFields on TripDesignerBio { avatar name bio } `,};type Props = TripDesignerBioFields \u0026amp; WithStylesProps;function TripDesignerBio({ avatar, name, bio, css, styles }: Props) { return ( \u0026lt;SectionWrapper\u0026gt; \u0026lt;div {...css(styles.contentWrapper)}\u0026gt; \u0026lt;Spacing bottom={4}\u0026gt; \u0026lt;UserAvatar name={name} size={AVATAR_SIZE_PX} src={avatar} /\u0026gt; \u0026lt;/Spacing\u0026gt; \u0026lt;Text light\u0026gt;{bio}\u0026lt;/Text\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/SectionWrapper\u0026gt; );}TripDesignerBio.fragments = fragments;export default withStyles(({ responsive }) =\u0026gt; ({ contentWrapper: { maxWidth: 632, marginLeft: 'auto', marginRight: 'auto', [responsive.mediumAndAbove]: { textAlign: 'center', }, },}))(TripDesignerBio);
这就是Airbnb后端驱动UI的一般性概念。它被用在很多地方,包括搜索、旅行计划、主机工具和各种登陆页面中。我们以此作为出发点,然后演示如何更新已有section和添加新section。
在开发产品时,你希望能够基于开发数据探索schema、发现字段并测试潜在的查询。我们借助实现了这一目标,这个工具是由Prisma提供的。
在我们的例子中,后端服务主要是使用Java开发的,我们的Apollo服务器(Niobe)负责拼接这些服务的schema。目前,由于Apollo Gateway和Schema Composition还没有上线,我们所有的后端服务都是按服务名称进行划分的。这就是为什么在使用Playground时需要提供一系列服务名。下一级是服务方法,比如getJourney()。
在开发产品时有这么多工具可用真的是太好了,比如在VS Code中访问Git,VS Code还提供了用于运行常用命令的集成终端和任务。
当然,除此之外,还有其他一些与GraphQL和Apollo有关的东西!大多数人可能还不知道新的。它提供的很多功能我在这里就不一一累述了,我只想介绍其中的一个:Schema Tag。
如果你打算基于正在使用的schema来lint你的查询,需要先决定是“哪个schema”。默认情况下可能是生产schema(按照惯例,就是“current”),但如果你需要进行迭代并探索新的想法,可能需要灵活地切换不同的schema。
因为我们使用的是Apollo Engine,所以使用标签发布多个schema可以实现这种灵活性,并且多个工程师可以在单个schema上进行协作。一个服务的schema变更被上游合并后,它们会被纳入当前的生产schema中,我们就可以在VS Code中切换回“current”。
代码生成的目标是在不手动创建TypeScript类型或React PropType的情况下利用强大的类型安全。这个很重要,因为我们的查询片段分布在各种组件中,同一个片段会在查询层次结构的多个位置出现,这就是为什么对查询片段做出1行修改就会导致6、7个文件被更新。
这主要是Apollo CLI的功劳。我们正在开发一个文件监控器(名字叫作“Sauron”),不过现在如果有需要,可以先运行:apollo client:codegen --target=typescript --watch --queries=frontend/luxury-guest/**/*.{ts,tsx}。
因为我们将片段和组件放在一起,所以当我们向上移动组件层次结构时,更改单个文件会导致查询中的很多文件被更新。这意味着在与路由组件越接近的位置(也就是树的更上层),我们可以看到合并查询以及所有相关的各种类型的数据。
我们使用来编辑UI,它为我们提供了快速的热模块重新加载功能和一些用于启用或禁用浏览器功能(如Flexbox)的复选框。
我使用来自API的模拟数据来加载story。如果你的模拟数据可以涵盖UI的各种可能状态,那么这么做就对了。除此之外,如果还有其他可能的状态(比如加载或错误状态),可以手动添加它们。
import alpsResponse from '../../../src/apps/PdpFramework/containers/__mocks__/alps';import getSectionsFromJourney from '../../getSectionsFromJourney';const alpsSections = getSectionsFromJourney(alpsResponse, 'TripDesignerBio');export default function TripDesignerBioDescriptor({ 'PdpFramework/sections/': { TripDesignerBio },}) { return { component: TripDesignerBio, variations: alpsSections.map((item, i) =\u0026gt; ({ title: `Alps ${i + 1}`, render: () =\u0026gt; ( \u0026lt;div\u0026gt; \u0026lt;div style={ { height: 40, backgroundColor: '#484848' }} /\u0026gt; \u0026lt;TripDesignerBio {...item} /\u0026gt; \u0026lt;div style={ { height: 40, backgroundColor: '#484848' }} /\u0026gt; \u0026lt;/div\u0026gt; ), })), };}
这个文件完全由Yeoman(下面会介绍)生成,默认情况下,它提供了来自Alps Journey的示例。getSectionsFromJourney()过滤了部分section。
另外,我添加了一对div,因为Storybook会在组件周围渲染空格。对于按钮或带有边框的UI来说这没什么问题,但很难准确分辨出组件的开始和结束位置,所以我在这里添加了div。
把所有这些神奇的工具放在一起,可以帮你提高工作效率。如果结合Zeplin或Figma使用Storybook,你的生活变得更加愉快。
为了在Storybook和单元测试中使用逼真的模拟数据,我们直接从共享开发环境中获取模拟数据。与代码生成一样,即使查询片段中的一个小变化也会导致模拟数据发生很多变化。这里最困难的部分完全由Apollo CLI负责处理,你只需要将生成的代码与自己的代码拼接在一起即可。
第一步只要简单地运行apollo client:extract frontend/luxury-guest/apollo-manifest.json,你将得到一个清单文件,其中包含了所有的查询。需要注意的是,这个命令指定了“luxury guest”项目,因为我不想刷新所有团队的所有可能的模拟数据。
我的查询分布在很多TypeScript文件中,这个命令将负责组合所有的导入。我不需要在babel/webpack的输出基础上运行它。
然后,我们只需要添加一小部分代码:
const apolloManifest = require('../../../apollo-manifest.json');const JOURNEY_IDS = [ { file: 'barbados', variables: { id: 112358 } }, { file: 'alps', variables: { id: 271828 } }, { file: 'london', variables: { id: 314159 } },];function getQueryFromManifest(manifest) { return manifest.operations.find(item =\u0026gt; item.document.includes(\u0026quot;JourneyRequest\u0026quot;)).document;}JOURNEY_IDS.forEach(({ file, variables }) =\u0026gt; { axios({ method: 'post', url: 'http://niobe.localhost.musta.ch/graphql', headers: { 'Content-Type': 'application/json' }, data: JSON.stringify({ variables, query: getQueryFromManifest(apolloManifest), }), }) .catch((err) =\u0026gt; { throw new Error(err); }) .then(({ data }) =\u0026gt; { fs.writeFile( `frontend/luxury-guest/src/apps/PdpFramework/containers/__mocks__/${file}.json`, JSON.stringify(data), (err) =\u0026gt; { if (err) { console.error('Error writing mock data file', err); } else { console.log(`Mock data successfully extracted for ${file}.`); } }, ); });});
我们目前正与Apollo团队合作,准备将这个逻辑提取到Apollo CLI中。我期待着将来我们只需要指定示例数组,并将它们和查询放在同一个文件夹中,然后根据需要自动生成模拟数据。想象一下我们只需要像这样指定模拟数据:
export default { JourneyRequest: [ { file: 'barbados', variables: { id: 112358 } }, { file: 'alps', variables: { id: 271828 } }, { file: 'london', variables: { id: 314159 } }, ],};
是我用过的唯一的一个屏幕截图测试工具,所以无法将它与其他工具(如果有的话)进行比较。它基本原理是这样的:你推送代码,它渲染PR的组件,将其与master上的版本进行比较。
如果你在编辑\u0026lt; Input/\u0026gt;之类的组件,它会显示你做的修改影响到了哪些依赖Input的组件。
不过,最近我们发现Happo唯一的不足是屏幕截图测试过程的输入并不总能充分反映出数据的可靠性。不过因为Storybook使用了API数据,我们会更加有信心。另外,它是自动化的,如果你向查询和组件中添加了一个字段,Happo会自动将差异包含到PR中,让其他工程师、设计师和产品经理看到变更后的视觉后果。
如果你需要多次搭建脚手架,那么应该先构建一个生成器,它可以帮你完成很多工作。除了AST转换(我将在下面介绍),这里是三个模板文件:
const COMPONENT_TEMPLATE = 'component.tsx.template';const STORY_TEMPLATE = 'story.jsx.template';const TEST_TEMPLATE = 'test.jsx.template';const SECTION_TYPES = 'frontend/luxury-guest/src/apps/PdpFramework/constants/SectionTypes.js';const SECTION_MAPPING = 'frontend/luxury-guest/src/components/PdpFramework/Sections.tsx';const COMPONENT_DIR = 'frontend/luxury-guest/src/components/PdpFramework/sections';const STORY_DIR = 'frontend/luxury-guest/stories/PdpFramework/sections';const TEST_DIR = 'frontend/luxury-guest/tests/components/PdpFramework/sections';module.exports = class ComponentGenerator extends Generator { _writeFile(templatePath, destinationPath, params) { if (!this.fs.exists(destinationPath)) { this.fs.copyTpl(templatePath, destinationPath, params); } } prompting() { return this.prompt([ { type: 'input', name: 'componentName', required: true, message: 'Yo! What is the section component name? (e.g. SuperFlyFullBleed or ThreeImagesWithFries)', }, ]).then(data =\u0026gt; { this.data = data; }); } writing() { const { componentName, componentPath } = this.data; const componentConst = _.snakeCase(componentName).toUpperCase(); this._writeFile( this.templatePath(COMPONENT_TEMPLATE), this.destinationPath(COMPONENT_DIR, `${componentName}.tsx`), { componentConst, componentName } ); this._writeFile( this.templatePath(STORY_TEMPLATE), this.destinationPath(STORY_DIR, `${componentName}VariationProvider.jsx`), { componentName, componentPath } ); this._writeFile( this.templatePath(TEST_TEMPLATE), this.destinationPath(TEST_DIR, `${componentName}.test.jsx`), { componentName } ); this._addToSectionTypes(); this._addToSectionMapping(); }};
你可以想象一下,原先需要一个下午才能完成的工作现在只需要2到3分钟就可以完成。
Yeoman生成器最困难的部分是如何编辑现有文件,不过,借助抽象语法树(AST)转换,这个任务变得更加容易。
以下是我们如何实现Sections.tsx的转换:
const babylon = require('babylon');const traverse = require('babel-traverse').default;const t = require('babel-types');const generate = require('babel-generator').default;module.exports = class ComponentGenerator extends Generator { _updateFile(filePath, transformObject) { const source = this.fs.read(filePath); const ast = babylon.parse(source, { sourceType: 'module' }); traverse(ast, transformObject); const { code } = generate(ast, {}, source); this.fs.write(this.destinationPath(filePath), prettier.format(code, PRETTER_CONFIG)); } _addToSectionMapping() { const { componentName } = this.data; const newKey = `[SECTION_TYPES.${_.snakeCase(componentName).toUpperCase()}]`; this._updateFile(SECTION_MAPPING, { Program({ node} ) { const newImport = t.importDeclaration( [t.importDefaultSpecifier(t.identifier(componentName))], t.stringLiteral(`./sections/${componentName}`) ); node.body.splice(6,0,newImport); }, ObjectExpression({ node }) { \t// ignore the tagged template literal if(node.properties.length \u0026gt; 1){ node.properties.push(t.objectTypeProperty( t.identifier(newKey), t.identifier(componentName) )); } }, TaggedTemplateExpression({node}) { const newMemberExpression = t.memberExpression( t.memberExpression( t.identifier(componentName), t.identifier('fragments') \t ), t.identifier('fields') ); node.quasi.expressions.splice(2,0,newMemberExpression);\tconst newFragmentLine = ` ...${componentName}Fields`; const fragmentQuasi = node.quasi.quasis[0]; const fragmentValue = fragmentQuasi.value.raw.split('\'); fragmentValue.splice(3,0,newFragmentLine); const newFragmentValue = fragmentValue.join('\'); fragmentQuasi.value = {raw: newFragmentValue, cooked: newFragmentValue}; const newLinesQuasi = node.quasi.quasis[3]; node.quasi.quasis.splice(3,0,newLinesQuasi); } }); }};
_updateFile是使用Babel进行AST转换的样板代码。这里最关键的是_addToSectionMapping,并且你可以看到:
如果执行转换的代码看起来令人生畏,我只能说,这对我来说也是如此。在写这些转换代码之前,我也还没用过quasi。
好在AST Explorer可以很容易地解决这类问题。这是同一个转换在Explorer中的。在四个窗格中,左上角包含源文件,右上角包含已解析的树,左下角包含建议的变换,右下角包含变换后的结果。
通过查看解析后的树,你就知道如何应用转换和测试它们了。
Zeplin和Figma的出现都是为了让工程师能够直接提取内容来提升产品开发效率。
如上所示,要提取整个段落的副本,只要在Zeplin中选择内容,并单击侧栏中的“复制”图标。
在Zeplin中,可以先选择图像,并单击侧栏“Assets”里的“下载”图标来提取图像。
照片处理管道肯定是Airbnb特有的。我想要强调的是Brie创建的用来包装现有API端点的“Media Squirrel”。如果没有Media Squirrel,我们就没有这么好的方法可以将我们机器上的原始图像转换为JSON对象,更不用说可以使用静态URL作为图像的源。
这部分工作仍在进行中,还不能作为最终的API。我们想要做的是拦截和修改远程schema和远程响应。因为虽然远程服务是事实的来源,但我们希望能够在规范化上游服务schema变更之前对产品进行迭代。
因为Apollo近期路线图中包含了Schema Composition和Distributed Execution,所以我们没有详细地解释所有细节,只是提出了基本概念。
实际上,Schema Composition允许我们定义类型,并像下面这样执行某些操作:
type SingleMedia { captions: [String] media: [LuxuryMedia] fullBleed: Boolean} extend type EditorialContent { SingleMedia}
在这种情况下,schema知道EditorialContent是一个联合,因此通过扩展它,它真的可以知道另一种可能的类型。
将Berzerker响应代码修改如下:
import { alpsPool, alpsChopper, alpsDessert, alpsCloser } from './data/sections/SingleMediaMock';const mocks: { [key: string]: (o: any) =\u0026gt; any } = { Journey: (journey: any) =\u0026gt; ({ ...journey, editorialContent: [ ...journey.editorialContent.slice(0, 3), alpsPool, ...journey.editorialContent.slice(3, 9), alpsChopper, ...journey.editorialContent.slice(9, 10), alpsDessert, ...journey.editorialContent.slice(10, 12), alpsCloser, ...journey.editorialContent.slice(12, 13), ], }),};export default mocks;
这里并没有使用mock填补API的空白,而是让它们保持原样,并根据你提供的东西对内容进行覆盖。
Apollo CLI负责处理所有与Apollo相关的事情,让你能够以更有意义的方式连接这些实用程序。其中一些用例(如类型的代码生成)是通用的,并且最终成为整个基础设施的一部分。
更多内容,请关注前端之巅(ID:frontshow)
英文原文:
转载地址:http://gygjx.baihongyu.com/