整合營銷服務商

          電腦端+手機端+微信端=數據同步管理

          免費咨詢熱線:

          把飛書云文檔變成HTML郵件:問題挑戰與解決歷程

          背景

          云文檔轉HTML郵件

          基于公司內部的飛書辦公套件,早在去年6月,我們就建設了將飛書云文檔轉譯成HTML郵件的能力,方便同學們在編寫郵件文檔和發送郵件時,都能有較好的體驗和較高的效率。

          當下問題

          要被郵件客戶端識別,飛書云文檔內容需要轉譯成HtmlEmail格式,該格式為了兼容各種版本的郵箱客戶端(特別是Windows Outlook),對于現代HTML5和CSS3的很多特性是不支持的,飛書云文檔的多種富文本塊格式都需要轉譯,且部分格式完全不支持,造成編輯和預覽發送不一致的情況。

          因此,我們對轉譯工具做了一次大改版和升級,對大部分常用文檔塊做了高度還原。

          實現效果

          經過我們的不懈努力,最終實現了較為不錯的還原效果:


          二、系統架構改版

          飛書云文檔結構

          在展開我們如何做升級之前,先要簡單了解下飛書云文檔的信息結構(詳情可參考官方API),在此僅做簡單闡述。

          TypeScript簡要定義,一個平鋪的文檔塊數組,根據block_id和parent_id確定各塊的父子關系,從而形成一個樹:

          {
            /** 文檔塊唯一標識。*/
            block_id: string;
            /** 父塊 ID。*/
            parent_id: string;
            /** 子塊 ID 列表。*/
            children: string[];
            /** 文檔塊類型。*/
            block_type: BlockType;
            /** 頁面塊內容描述。*/
            page?: { ... };
            /** 文本塊內容描述。*/
            text?: { ... };
            /** 標題 1 塊內容描述。*/
            heading1?: { ... };
            /** 有序列表塊內容描述。*/
            ordered?: { ... };
            /** 表格塊內容描述。*/
            table?: { ... };
            // 總計 43 個塊定義。
            ...
          }[];

          我們用思維導圖簡單舉例,整個文檔塊的樹結構大致是這樣的,有些塊根據縮進遞進,會形成父子關系,有些塊天然就會成為父塊(比如表格、引用等):

          舊版架構

          那么我們初版轉譯工具是怎么做的呢,比較遺憾的是,由于當時需求的還原度訴求較低,我們的代碼主要是復用現有部分實現,整體的架構設計可以用一個詞概括,基本是面向過程編程:

          上方的圖:經過了一些抽取和封裝,主流程核心代碼仍有528行;下方的圖:文檔塊核心轉譯渲染代碼,基本沒有寫任何還原樣式,通過Switch、Case來一個個渲染文檔塊。

          新版架構設計

          這次我們痛定思痛,勢必要將轉譯工具的轉譯效果做到盡可能還原,也有了多位同學一起投入。因此首要思考和急需解決的問題來了:在老舊的架構下,如何才能做好代碼擴展、多人協同、高效樣式編寫以及樣式還原?

          IoC 與DI

          是的,幾乎一剎那,憑借過往豐富的多人協同以及項目經驗,很快我們就想到了,這個事需要基于IoC的設計原則,并通過DI的方式來實現。

          那么什么是IoC和DI呢,根據維基百科的解釋:控制反轉(Inversion of Control,縮寫為IoC),是面向對象編程中的一種設計原則,可以用來減低計算機代碼之間的耦合度,其中最常見的方式叫做依賴注入(Dependency Injection,縮寫為DI)。

          這么說可能有點抽象,我們可以看下新版的架構設計,從中便能窺見其精妙:

          可以看到,關鍵的文檔塊預處理和渲染器,在該架構中是反向依賴核心的createDocTranspiler了,與我們常識中的理解(文檔轉譯渲染依賴各個塊的預處理和渲染器)是相反的,這就是控制反轉(IoC),通過這樣的依賴倒置,我們能夠把多人協同過程中,由各個同學負責開發的預處理器和渲染器的開發調試解耦出去,互不影響、互不依賴,且合碼過程中基本沒有代碼沖突,大大提效了多人協同合作開發。同時由于實現的方式是依賴注入(DI),或者說注冊,未來我們想要支持更加深水區的文檔塊,比如「畫板」、「文檔小組件」等,可以很方便地注冊新的預處理器和渲染器,做增量且解耦的代碼開發;如果想要取消對某一個文檔塊的渲染,直接unregister即可,由此也實現了文檔塊渲染的快速插拔和極高的可拓展性。

          整個轉譯主干代碼如下:

          創建轉譯器,注冊預處理器,注冊渲染器

          轉譯渲染,后處理,完成渲染。代碼行數縮減到只有138行。


          函數式編程

          接下來我們將目光聚焦到核心函數createDocTranspiler中,這塊是IoC架構的核心實現,根據維基百科描述,IoC是面向對象編程中的一種設計原則,那么我們真的是用面向對象的編程方式嗎?

          顯然不是,我們是高標準的前端同學,在JavaScript編程中,面向對象編程顯然不是社區推崇的設計原則,以React框架為例,早在React 16.8版本,就推出了函數組件和Hooks編程,以取代較為臃腫的類組件編程,這些都是前端老生常談的理念了,大家可以去Google深入學習函數式編程理念,在此不再贅述。

          這里說一下為什么核心代碼createDocTranspiler我要用函數式編程,說一下我的理解:第一是非常優雅,用起來很舒服;第二是得益于JavaScript函數閉包,一些局部(想要private化)的變量或者方法,直接在函數內聲明和定義即可,不用擔心像類一樣會暴露出去(盡管TS有private關鍵字,但只是約束,不代表你不能用);第三是簡單,無需維護類的實例,若有主動銷毀場景,返回的結構中暴露銷毀函數即可。

          整個核心代碼如下:

          上方的圖:內置的變量和函數,用于存儲各種預處理器和渲染器,并實現文檔樹的遞歸渲染;下方的圖:返回并暴露出去的函數,用于注冊各種預處理器、渲染器,以及轉譯渲染。整個核心代碼只有158行,非常精煉。

          “CSS-in-JS”

          然后再來說一下如此大量的樣式還原工作,我們是如何實現的。由于我們要把文檔樹轉譯成最終的一個完整的HTML字符串,在模板字符串中寫內聯樣式(style="width: 100px;...")會非常痛苦,代碼可讀性會很差,開發調試的效率也會很低。

          為了解決這個問題,我們立即想到了React CSSProperties的寫法,并調研了一下它的源碼實現,其實就是將CSSProperties中的駝峰屬性名,轉換成內聯樣式中連字符屬性名,并額外處理了Webkit、ms、Moz、O等瀏覽器屬性前綴,同時針對number 類型的部分屬性的值,轉換時自動加上了px后綴。詳細代碼如下:

          // 樣式處理工具函數庫。
          import { CSSProperties } from 'react';
          
          
          /* 是否是,值可能是數字類型,且不需要指定 px 為單位的 CSSProperties 屬性。*/
          const isUnitlessNumber: Record<string, boolean> = {
            // ...
            fontWeight: true,
            lineClamp: true,
            lineHeight: true,
            // ...
          
          
            // SVG-related properties.
            fillOpacity: true,
            floodOpacity: true,
            stopOpacity: true,
            // ...
          };
          
          
          // 各瀏覽器 CSS 屬性名前綴。
          const cssPropertyPrefixes = ['Webkit', 'ms', 'Moz', 'O'];
          
          
          // 針對 isUnitlessNumber,填充各瀏覽器 CSS 屬性名前綴。
          Object.keys(isUnitlessNumber).forEach(property => {
            cssPropertyPrefixes.forEach(prefix => {
              isUnitlessNumber[`${prefix}${property.charAt(0).toUpperCase()}${property.substring(1)}`] =
                isUnitlessNumber[property];
            });
          });
          
          
          export { isUnitlessNumber };
          
          
          /** 針對 CSSProperties 屬性值,可能添加單位 px,并返回合法的值。*/
          export function addCSSPropertyUnit<T extends keyof CSSProperties>(property: T, value: CSSProperties[T]) {
            if (typeof value === 'number' && !isUnitlessNumber[property]) {
              // 值是數字類型,且需要添加單位 px,則添加單位 px。
              return `${value}px`;
            }
            return value;
          }

          然后再編寫createInlineStyles方法,入參即為Record<string, CSSProperties> 大樣式對象:

          /* 將 CSSProperties 轉為內聯 style 字符串,e.g. { width: 100, flex: 1 } => style="width: 100px; flex: 1;"。*/
          export function convertCSSPropertiesToInlineStyle(style: CSSProperties) {
            const upperCaseReg = /[A-Z]/g;
          
          
            const inlineStyle = Object.keys(style)
              .map(
                property =>
                  `${property.replace(
                    upperCaseReg,
                    matchLetter => `-${matchLetter.toLowerCase()}`,
                  )}: ${addCSSPropertyUnit(property as keyof CSSProperties, style[property])};`,
              )
              .join(' ');
          
          
            if (inlineStyle) {
              return `style="${inlineStyle}"`;
            }
          
          
            return '';
          }
          
          
          /** 根據輸入的樣式表(CSSProperties 格式),輸出內聯樣式表(格式為 style="..." 的字符串),e.g. { container: { position: 'relative' }, title: { fontSize: 18 } } => { container: 'style="position: relative;"', title: 'style="font-size: 18px;"' }。*/
          export function createInlineStyles<T extends string>(styles: { [P in T]: CSSProperties }) {
            const inlineStyles = {} as { [P in T]: string };
          
          
            Object.keys(styles).forEach(name => {
              inlineStyles[name] = convertCSSPropertiesToInlineStyle(styles[name]);
            });
          
          
            return inlineStyles;
          }

          至此架構優化的差不多了,整個項目組進入了高度協同、緊密溝通合作的開發中,整個開發過程其實并不是特別順利,尤其是在對Windows Outlook郵箱客戶端的支持上,各種樣式兼容問題Case層出不窮,以至于我們的開發同學不得不去對郵箱HTML和CSS開發進行“考古”。

          三、Outlook麻煩的兼容性問題

          在改版系統架構后,我們先試著實現了一版有序列表和無序列表的解決方案,結果在測試中,我們得到了出乎所有人意料之外的結果:

          原本文檔的樣子

          網頁版Outlook中的樣子

          Windows的Outlook中的樣子

          在網頁版Outlook中,通過開發工具可以看到每一項的justify-content樣式消失了,而在Windows Outlook中,基本沒什么樣式還留著了。

          Outlook糟糕的兼容性

          我們之前從未編寫過HTML郵件,也就完全沒考慮過各個郵件客戶端對HTML的兼容性問題。在網上找到一些資料后,我們被Outlook對HTML的兼容性之差所震驚。

          首先,Windows Outlook并沒有一個自己的HTML渲染引擎,而是使用Word的渲染引擎去解析HTML。它不支持HTML5和CSS3,也就是說我們為了保證最大的兼容性,所有的飛書文檔樣式還原和文本解析都要用極為陳舊的技術去實現。

          據官方文檔所示,display、position、max-width、max-height等樣式全都不兼容。

          總的來說:

          • 不能使用任何CSS3新特性,比如flex、grid等;
          • 和布局有關的組件,只能使用table來進行布局;
          • 只能使用行內樣式;盡量只使用table、tr、td、span、img、a、div這幾個標簽;
          • 只有div的margin會偶爾被正確地識別,其它標簽都有可能讓padding和margin消失;
          • 如果一個div內部含有table,它的margin會讓table背景色和邊框混亂;無法使用line-height;
          • 小心使用div,Outlook有時候會把他轉換為p,具體邏輯還不明確;
          • 圖片唯一能夠控制大小的方法就是使用img標簽上的width屬性和height屬性。

          技術上的限制如此苛刻,就意味著在后面的開發中,我們還會遇到很多特定情況的兼容性問題。在這種情況下,為了最大限度地保證兼容性,我們決定及時止損,重新設計后面各個組件的實現方式,并將無序列表和有序列表的渲染方法推倒重來,再次編寫。

          四、各類型文檔塊的還原

          首先,我們將轉譯工具原有的「一級標題」到「九級標題」美化為接近飛書文檔的樣子。我們需要梳理下將會獲得的數據,來看看如何將它們轉譯為HTML。

          標題塊(heading 1-9)

          標題組件應該是實現難度最低的一個,一個標題組件的數據結構如下:

          原版實現方式

          在原版的轉譯工具中,我們編寫了通用方法來處理文本內容的下劃線、刪除線、斜體、粗體、高亮色等進行處理,生成行間元素,然后在外部框上<h1>-<h9>。最終在后面加上它的子節點渲染結果。

          新版實現方式

          由于默認的heading樣式無法滿足還原度,且并沒有處理對齊方式。我們將使用 <div> 制作heading組件,自行添加樣式來還原飛書文檔:

          case BlockType.HEADING1: {
            const blockH1 = block as HeadingBlock;
            const align = blockH1.heading1.style.align;
            const styles = makeHeadingStyles({ type: block.block_type, align });
            text += `<div ${styles.headingStyles}>${transpileTextElements(
              blockH1.block_id,
              blockH1.heading1.elements,
              isPreview,
            )}</div>`;
            // renderChildBlocks 方法來渲染當前塊的所有子節點。
            text += renderChildBlocks(blockH1.block_id);
            break;
          }


          其中makeHeadingStyles是我們生成樣式的方法,這樣可以將各個組件的樣式寫成配置項,方便后續修改。新的樣式中,我們著重對行高、行距、下劃線距文字距離、對齊方式進行了調整:

          // makeHeadingStyles 方法的部分截取。
          export function makeHeadingStyles(params: MakeHeadingStylesParams) {
            const { type, align } = params;
            const basicStyle: CSSProperties = {
              lineHeight: 1.4,
              letterSpacing: '-.02em',
              fontWeight: 500,
              color: '#1f2329',
              textAlign: getTextAlignStyle(align || 1),
            };
          
          
            let headingStyles: CSSProperties = {};
            switch (type) {
              case BlockType.HEADING1:
                headingStyles = {
                  fontSize: 26,
                  marginTop: 26,
                  marginBottom: 10,
                  ...basicStyle,
                };
                break;
              // 對Heading2-9的樣式進行定義...
            // ......
          
          
            // 將樣式對象轉成行間樣式字符串。
            return createInlineStyles<'headingStyles'>({ headingStyles: headingStyles });
          }

          最后發郵件,測試一下生成的HTML的效果:

          改版之前

          改版之后


          無序列表(bullet)與有序列表(ordered)

          原版實現方式

          列表的數據結構與標題塊大致相同,在此不再贅述。在原來的轉譯工具中,我們使用原生的<ul>和<li>來直接渲染無序列表,<ol><li>來渲染有序列表。我們順序遍歷兄弟節點的列表,為連續的bullet文檔塊的前后加上<ul></ul>,連續的ordered塊前后加上<ol>和</ol>。列表中的每一項,則渲染成<li>。

          由于原生<ul>和<ol>的marker樣式較丑,我們無法使用偽類元素等手段改善它的樣式,為了方便,我們這次改版將自己維護列表的層級關系。

          新版實現方式

          在飛書文檔中,不同層級的列表,marker長得完全不同:

          無序列表

          有序列表

          為了判斷我們每個列表項要使用什么樣的marker,首先我們需要對飛書給我們的數據進行預處理,為每個列表塊標注它的層級和序號。

          • 數據預處理器

          由于飛書API沒有提供有序列表的序號,這個序號用戶又可以隨便更改,所以我們的思路是:如果有序列表中間被非空文檔塊以外的文本塊截斷,序號則重新開始計算。具體方法如下:

          /** 判斷文本塊是否為空白文本類型的快。*/
          export function isEmptyTextBlock(block: DocBlockText | undefined) {
            if (文檔塊的類型為text且不為空 || 文檔塊類型不為text) {返回false;}
            else {返回true;}
          }
          
          
          /** 為每個文本塊計算它到文本樹根節點的深度,為有序列表塊找到它的序號。*/
          export function processBlocks(blocks: DocBlock[]) {
            const blockDepths = {}; // 記錄各節點距根節點的深度。
            const blockOrder = {}; // 記錄各節點在同類兄弟節點中的順序,被其他類型的塊打斷的時候將重新計數。
            function calcBlockFields(block: DocBlock, depth: number) {
              blockDepths[block.block_id] = depth;
          
          
              // 為有序列表找到它的序號。
              if (文本塊類型為 ordered) {
                1. 找到同級兄弟節點列表 brotherBlocks 與同類型同級兄弟節點列表 similarBrotherBlocks;
                2. 找到當前節點在上述兩個列表中的索引 brotherBlocksIndex,similarBrotherBlocksIndex;
                3. 找到兄弟節點列表中的前一個節點 prevBrotherBlock。以及同類兄弟列表的前一個節點 prevSimilarBrotherBlock;
          
          
                if (當前節點是兄弟節點列表中的第一個節點 || 當前節點是同類兄弟節點列表中的第一個節點 || 前一個兄弟節點不是同類兄弟節點,且前一個兄弟節點是非空的文本塊) {
                  blockOrder[block.block_id] = 1;
                } else {
                  blockOrder[block.block_id] = 上一個同類兄弟的編號 + 1
                }
              }
              遞歸處理子節點。如果當前節點的類型為 grid_column、tabel_cell、callout、quoter_container 的時候,深度重置為 1(calcBlockFields(childrenBlock, 1)),其他情況 calcBlockFields(childrenBlock, depth + 1);
            }
          
          
            從根節點開始遞歸處理。calcBlockFields(rootBlock, 0);
            將記錄的序號和深度(blockOrder, blockDepths)添加到每個節點中(block.depth, block.order);
          }

          這樣,每個列表項都知道了自己在文檔中的層級,有序列表也知道了自己的序號。

          由于原來的方法中完全沒有處理過文本塊的縮進,我們根據飛書縮進的規律,為普通的文本塊(表格、柵格等以外的文本塊)在渲染子節點時為子節點的容器添加25px的padding-left。

          接下來我們使用一個通用的方法為有序列表和無序列表渲染它們的marker。

          • 列表標號渲染器
          /** 渲染列表的標簽。*/
          export const listMarkRender = (type: ListType, block: DocBlock) => {
            const { depth = 1, order = 1 } = block;
          
          
            if (type === ListType.BULLET) {
              const styles = makeMarkerStyles(ListType.BULLET);
              let marker: string;
              marker = 按照深度,每三個一循環,依次為 '?'、'?'、'?';
              return `<span ${styles.markContainerStyle}>${marker}</span>`;
            } else {
              const styles = makeMarkerStyles(ListType.ORDERED);
              let markerGenerator: (num: number) => number | string;
              markerGenerator = 按照深度,每三個一循環,依次為數字、數字轉小寫字母、數字轉羅馬數字;
              return `<span ${styles.markContainerStyle}>${markerGenerator(order)}.</span>`;
            }
          };

          對于無序列表,標號每三層一循環,順序為 '?'、'?'、'?'。對于有序列表,標號格式也是每三層一循環,順序為阿拉伯數字、小寫字母、羅馬數字。

          使用列表的標號渲染器渲染標號部分,然后簡單的在<div>中將標號<span>和處理過樣式的正文<span>組合。

          • 無序列表與有序列表渲染器
            • 新版有序列表渲染器
              • 渲染器:
          const orderedRenderer: BlockRenderer = (block, isPreview, renderChildBlocks) => {
            const orderedBlock = block as OrderedBlock;
            const align = orderedBlock.ordered.style.align;
            const styles = makeOrderedStyles(align);
          
            let text = '';
            text += `
              <div ${styles.listWrapper}>
                ${listMarkRender(ListType.ORDERED, orderedBlock,)}
                <span ${styles.listContent}>
                ${transpileTextElements(orderedBlock.block_id, orderedBlock.ordered.elements, isPreview,)}
                </span>
              </div>
              `;
            text += renderChildBlocks(orderedBlock.block_id, false);
          
            return text;
          };
          • 無序列表渲染器
            • 渲染器
          const bulletRenderer: BlockRenderer = (block, isPreview, renderChildBlocks) => {
            const bulletBlock = block as BulletBlock;
            const align = bulletBlock.bullet.style.align;
            const styles = makeBulletStyles(align);
          
            let text = '';
            text += `
              <div ${styles.listWrapper}>
                ${listMarkRender(ListType.BULLET, bulletBlock,)}
                <span ${styles.listContent}>${transpileTextElements(
                    bulletBlock.block_id,
                    bulletBlock.bullet.elements,
                    isPreview,
                )}</span>
              </div>`;
            text += renderChildBlocks(bulletBlock.block_id, false);
          
            return text;
          };
          • 最終呈現結果

          可以看到,我們在滿足使用的前提下以最高的兼容性比較完美的還原了飛書文檔中的有序列表和無序列表。

          待辦事項

          既然漂亮地還原了有序列表和無序列表,待辦事項塊就簡單得多了。代辦事項的具體的數據結構如下:

          可以看到,待辦事項的數據中包含了該條待辦事項是否已完成的數據,從飛書文檔的樣式可以看出,已完成的條目會統一被劃上刪除線,并刪除下劃線樣式。最終的渲染器和樣式生成方法如下:

          待辦事項渲染器

          • 渲染器:
          const todoRenderer: BlockRenderer = (block, isPreview, renderChildBlocks, _blocks) => {
            const todoBlock = block as TodoBlock;
            const { align, done } = todoBlock.todo.style;
            const originTodoElements = todoBlock.todo.elements;
            const markerSrc = done ? '已完成標記圖片地址' : '未完成標記圖片地址';
            const styles = makeTodoStyles(align || 1, done);
            
            const checkedTodoElements = cloneDeep(originTodoElements);
            checkedTodoElements.forEach(element => {
              為所有文本元素去掉下劃線,添加刪除線
            });
          
            let text = '';
            text += `
              <div ${styles.todoWrapperStyles}>
                <img width="18" height="18" ${styles.todoMarkerStyles} src="${markerSrc}" alt="todo_mark"/>
                <span> </span>
                <span ${styles.todoContentStyles}>${transpileTextElements(
                  todoBlock.block_id,
                  done ? checkedTodoElements : originTodoElements,
                  isPreview,
                )}</span>
              </div>`;
            text += renderChildBlocks(todoBlock.block_id, false);
            return text;
          };

          最終呈現效果


          表格(非電子表格)塊

          文檔中另一個最重要的模塊就是表格。表格是另一類比較特殊的文本塊,他內部并不包含正文。整個表格實際上由三層文檔塊組合而成,它們的數據結構如下:

          依據數據結構和我們的代碼模式設計,我們需要使用嵌套的渲染器來實現表格的繪制。

          表格渲染器(table塊)

          由于飛書API中清楚地提供了行數、列數以及列寬,我們可以較為輕松地繪制出大致的表格。這里的重點是要準確地處理合并單元格數據,將它們精準地使用在表格的每個 <td>標簽上。表格渲染器的代碼如下:

          • 渲染器:
          const tableRenderer: BlockRenderer = (block, renderSpecifyBlock) => {
            const blockTable = block as TableBlock;
            const children = blockTable.table.cells;
            const tableStyles = makeTableStyles();
          
            const { column_size, row_size, column_width, merge_info } = blockTable.table.property;
            // 計算出整個表格的整體寬度。
            const totalWidth = column_width.reduce((acc, cur) => acc + cur, 0);
            let text = `
              <div ${tableStyles.tableWrapperStyles}>
                <table width="${totalWidth}" ${tableStyles.tableStyles}>
              `;
            // 初始化單元格處理標記數組,記錄哪些單元格已被處理過數據。
            const processed = Array.from({ length: row_size }, () => Array(column_size).fill(false));
            let mergeIndex = 0; // 追蹤當前 merge_info 索引。
            
            for (let i = 0; i < row_size; i++) {
              text += '<tr>';
              for (let j = 0; j < column_size; ) {
                從 merge_info[mergeIndex] 獲取當前合并信息 col_span 與 row_span,確保 col_span 和 row_span 至少為 1;
                
                // 如果當前單元格未處理過,則進行處理。
                if (!processed[i][j]) {
                  const tDStyles = makeTDStyles(column_width[j]);
                  const colspanAttr = col_span > 1 ? `colspan="${col_span}"` : '';
                  const rowspanAttr = row_span > 1 ? `rowspan="${row_span}"` : '';
                  text += `
                    <td valign="top" width="${column_width[j]}" ${colspanAttr} ${rowspanAttr} ${
                    tDStyles.tDStyles
                  }>
                      // 與之前的文檔塊直接渲染所有的子節點不同,表格需要在單元格內精準的渲染對應的 table cell 塊,所以此處使用 renderSpecifyBlock 方法。
                      ${renderSpecifyBlock(children[i * column_size + j])}
                    </td>
                    `;
                  // 更新處理標記數組,標記當前單元格及其被合并的單元格為已處理,
                  for (let m = i; m < Math.min(i + row_span, row_size); m++) {
                    for (let n = j; n < Math.min(j + col_span, column_size); n++) {
                      processed[m][n] = true;
                    }
                  }
                  j += col_span; // 跳過被合并的單元格。
                  mergeIndex += col_span; // 跳過被合并的單元格對應的 merge_info。
                } else {
                  j++;
                  mergeIndex++;
                }
              }
              text += '</tr>';
            }
            text += '</table></div>';
          
            return text;
          };

          為了處理合并單元格數據,我們維護了一個已處理標記數組processed,處理完一個單元格后,我們將當前單元格與被它合并的單元格都標記為已處理,來跳過他們的處理與渲染。這里需要特別注意,飛書文檔的接口偶爾會返回錯誤的合并單元格數據:{ row_span: 0, col_span: 0 },這個現象已經反饋給飛書,我們在34-37行做了兼容處理。

          為了最大限度的兼容性,我們堅持能用標簽屬性設置的樣式,就不使用CSS來設置。與列表的渲染不同,在表格中我們沒有像列表渲染一樣先預處理數據再生成DOM字符串,而是使用了在遍歷中邊處理數據邊生成DOM字符串的方法。

          在表格的渲染中,我們沒有像之前的代碼一樣使用renderChildBlocks把所有子文檔塊都渲染出來添加進HTML字符串中,而是使用了新的renderSpecifyBlock方法,給定block_id來渲染特定的子文檔塊。

          單元格容器渲染器(table cell塊)

          單元格容器的渲染器則簡單的多,他沒有任何數據處理,只繪制一個容器用于承載內部的所有子節點,并在內部將單元格內的子節點渲染出來

          • 渲染器:
          const tableCellRenderer: BlockRenderer = (block, isPreview, renderChildBlocks, _blocks) => {
            const styles = makeTableCellStyles();
          
            return `
              <div ${styles.tableCellWrapperStyle}>
                ${renderChildBlocks(block.block_id, true)}
              </div>`;
          };

          最終呈現效果

          圖片塊

          圖片塊理應也是一個很容易實現的文檔塊。但在實際處理過程中,由于飛書的API只提供圖片源文件的寬高,并沒有提供云文檔中用戶縮放過后的圖片寬高,我們需要實現一個能滿足絕大多數使用場景的圖片縮放算法來盡可能還原文檔中的圖片樣式。

          圖片塊的數據結構如下:

          限制圖片大小

          源文件的寬高一般都遠大于圖片在云文檔中的實際寬高。我決定使用以下的方法來限制住圖片在文檔中的寬高:

          • 若圖片處于類似表格的文檔塊中,則寬度撐滿父容器;
          • 若圖片不在類似表格的文檔塊中,則按照maxHeight: 780(限制最大高度避免長圖過長),maxWidth: 820(飛書文檔最大寬度),使用如下的算法來計算縮放后的圖片大?。?/span>

          • 最后我們在樣式中設置maxWidth = 100%(在Windows的Outlook中不會生效)來在大多數客戶端中保證圖片寬度不會撐出父容器。

          上述算法的代碼實現如下:

          • 查找父容器中是否有表格容器:
          /** 根據 id 找到塊。*/
          function findNodeById(blocks: DocBlock[], id: string) {
            return blocks.find(b => b.block_id === id);
          }
          
          /** 檢查當前塊的父節點中有沒有表格或柵格塊。*/
          function checkIsInTable(blocks: DocBlock[], parentId: string) {
            const parentNode = findNodeById(blocks, parentId);
            if (parentNode) {
              if (WRAPPERS_LIKE_TABLE.includes(parentNode.block_type)) {
                return true;
              }
              return checkIsInTable(blocks, parentNode.parent_id);
            }
            return false;
          }
          • 限制圖片寬高:
          function restrictImageSize(
            width: number,
            height: number,
            maxWidth: number = 820,
            maxHeight: number = 780,
          ): [number, number] {
            // 寬和高按照長邊縮放(高度大于寬度 50px 視為長圖),并為縮放后的寬高向上取整。
            if (width >= height - 50) {
              if (width > maxWidth) {
                return [maxWidth, Math.ceil(height * divide(maxWidth, width))];
              }
            } else {
              if (height > maxHeight) {
                return [Math.ceil(width * divide(maxHeight, height)), maxHeight];
              }
            }
            return [width, height];
          }

          圖片渲染器

          • 渲染器:
          const imageRenderer: BlockRenderer = (block, isPreview, _renderChildBlocks, blocks) => {
            let text = '';
            const blockImage = block as DocBlockImage;
            const align = blockImage.image.align;
            const src = `"${
              isPreview ? blockImage.image.base64Url : `\$\{${blockImage.block_id}\}` // 實際發送時,用 ${block_id} 作為占位符,給到服務端填充圖片附件地址。
            }"`;
            const [width] = restrictImageSize(blockImage.image.width, blockImage.image.height);
            const isInTable = checkIsInTable(blocks, blockImage.parent_id);
            const styles = makeImageStyles({ width, align, isInTable });
          
            text += `
              <div ${styles.imgWrapperStyle}>
                <img width="${isInTable ? '100%' : width}" ${styles.imgStyle} src=${src}>
              </div>
          `;
          
            return text;
          };

          在預覽的時候,我們將圖片地址設為圖片的base64,直接展示。最后傳給后端的HTML字符串中,我們將圖片地址設為一個占位符,供后端解析并轉化為郵件附件地址。

          使用表格來布局的幾個文檔塊

          由于Windows Outlook對CSS的支持程度很差,我們在對一些復雜文檔塊進行排版布局的時候不能使用flex、grid等。且display和position屬性在大多情況下也不會像預期那樣正常生效。我們為了最大的兼容性只能使用表格來解決一切排版問題。代碼塊、高亮塊、柵格等幾個文檔塊就都遵循了這個思路,使用表格來解決排版。我們以最復雜的代碼塊作為代表來進行介紹。

          代碼塊

          飛書云文檔中免不了會出現代碼,所以較好的進行代碼塊的還原也是個重要的工作。代碼塊還原的一個難點就是數據的處理,首先介紹下代碼塊的數據結構:

          理想的話,我們希望element中每一項為一行代碼,我們挨個進行渲染即可。但實際上,element的內容和普通文本類似,只要文本的樣式不變(比如設為斜體、加粗等),這些文本就都會被塞到同一個element項中。

          舉例說明,對于下列文檔中的代碼塊,實際飛書API返回的代碼只有兩項element:

          其中,最后一個大括號被單獨拆成一項令人費解,不過好在代碼塊中,只要一項element的后面出現了另一項,那就一定意味著換行。這減少了我們的處理難度。

          • 數據處理

          我們的大體思路,是將代碼拆分成一個二維數組。第一維中的每一維度為一行代碼,每行代碼中的每一維度為拆分后零碎的代碼塊。我們先將所有的element中的內容根據換行符\n拆分成一個個細小的子塊,同時將與HTML有關的字符替換成HTML編碼,避免這些字符混入HTML字符串中被當做標簽解析:

          elements.forEach(element => {
            const textStyles = element.text_run?.text_element_style;
            const elementSplit = (element.text_run?.content || '')
              .replaceAll('&', '&')
              .replaceAll('<', '<')
              .replaceAll('>', '>')
              .replaceAll('"', '"')
              .replaceAll("'", ''')
              .match(/(.*?\n|.+)/g);
            elementSplit &&
              elementSplit.forEach(line => {
                codeList.push({
                  text_run: {
                    content: line,
                    text_element_style: textStyles as TextElementStyle,
                  },
                });
              });
          });

          然后將這些子塊按照換行符進行分組,變成我們需要的二維數組:

          /** 將拆分好的代碼塊列表按行進行分組。*/
          const groupingCodeList = (list: TextElement[] = []) => {
            const result: TextElement[][] = [];
            let currentGroup: TextElement[] = [];
          
            list.forEach(item => {
              // 將當前字符串添加到當前分組。
              currentGroup.push(item);
              // 如果字符串包含 '\n',則結束當前分組,并準備開始新的分組。
              if (item.text_run?.content.includes('\n')) {
                result.push(currentGroup);
                currentGroup = [];
              }
            });
          
            // 最后將 currentGroup 中剩余的項目加入 result。
            if (currentGroup.length > 0) {
              result.push(currentGroup);
            }
          
            return result;
          };

          至此,我們知道了代碼行數n和每行代碼中的小代碼塊有哪些。我們要做的就是將它們放進一個n行2列的表格中

          • 代碼塊渲染器

          最終,代碼塊渲染器的代碼如下。為了保證最大的兼容性,我們使用空的表格行作為內邊距,盡量避免CSS解析問題:

            • 渲染器:
          const codeRenderer: BlockRenderer = (block, isPreview, renderChildBlocks, _blocks) => {
            const styles = makeCodeStyles();
          
            const blockCode = block as DocBlockCode;
            const codeLanguage = blockCode.code.style.language || 0;
          
            // 將代碼塊中的正文將帶 \n 的分割開。
            const codeList: TextElement[] = [];
            const elements = blockCode.code.elements;
          
            // 分割的時候把 HTML 有關的字符換成 HTML 編碼,避免這些正文直接被當成 HTML 渲染。
            上文中提到的對elements的處理...
          
            const groupedCodeLines = groupingCodeList(codeList);
            // 將按行分類好的代碼塊填入 td。
            const codeTr = groupedCodeLines
              .map((line, index) => {
                return `
                <tr bgcolor="f5f6f7">
                  <td width="46" align="right" valign="top">
                    <pre ${styles.codeIndexStyles}>${index + 1}</pre>
                  </td>
                  <td>
                    <pre ${styles.codePreStyles}>${transpileTextElements(blockCode.block_id, line, isPreview,)}</pre>
                  </td>
                </tr>
                `;
              })
              .join('');
          
            const emptyTr = `
              <tr bgcolor="f5f6f7">
                <td width="46" align="right"><span> </span></td>
                <td><pre ${styles.codePreStyles}> </pre></td>
              </tr>
              `;
          
            let text = `
            <div ${styles.codeWrapperStyles}>
              <table width="100%" ${styles.codeTableStyles}>
                ${emptyTr}
                ${codeTr}
                ${emptyTr}
              </table>
            </div>
            `;
          text += renderChildBlocks(blockCode.block_id, false);
          return text;
          };


          • 樣式生成:

          我們本次不會實現代碼的高亮,只會顯示同一種顏色的代碼。對表格中的每個單元格,我們使用pre標簽包裹來保留代碼中的制表符、空格,并將fontFamily設置為'Courier New', Courier, monospace,使用等寬字體來呈現代碼。

          • 最終呈現效果:

          行間公式

          飛書云文檔除文本外支持多種行間元素的插入,比如@文檔、內聯文件、內聯公式等,在此我們介紹下最為復雜的內聯公式是怎么處理的。

          行間公式的數據位于各個文檔塊的內聯塊中,以文本塊為例,具體數據如下:

          我們要做的,就是將公式轉換為圖片,然后在郵件中將公式作為圖片附件來處理。

          • 公式數據的預處理

          我們將使用MathJax來將公式表達式轉換為svg,用于用戶預覽。在發送時,我們將MathJax生成的svg通過cavans轉化為png圖片,上傳到CDN,并將CDN地址給到后端,進行郵件附件轉換。

          公式的預處理方法如下:

          // 公式發送時,后端渲染完成的圖片,其展示的高度的系數。
          const equationCoefficient = 8.421;
          
          const enrichEquationElements: BlockPreprocessor = async (blocks, isPreview) => {
            if (!window.MathJax) {
              await loadScript('https://cdn.dewu.com/node-common/bc7b5cfc-1c7c-e649-710a-929f109e505e.js');
            }
          
            const equationSVGList: SvgObj[] = []; // 待上傳的公式列表。
            const equationElementList: TextElement[] = []; // 帶有公式的元素列表。
          
            blocks.forEach(block => {
              const elements = getBlockElements(block);
              let equationIndex = 0;
          
              elements.forEach(textEl => {
                // 文本塊內容中包含公式時,轉譯為 SVG HTML。
                if (textEl.equation) {
                  equationElementList.push(textEl);
                  const equationId = `${block.block_id}_equation_${++equationIndex}`;
                  const svgEl = window.MathJax.tex2svg(textEl.equation.content).children[0];
          
                  // 由于生成的公式 svg 的高度使用 ex 單位,這里乘以一個參數來轉成近似的 px 單位。
                  const svgHeight = svgEl的ex高度 * equationCoefficient;
                  const svgWidth = svgEl的ex寬度 * equationCoefficient;
          
                  textEl.equation.svgHTML = svgEl.outerHTML;
                  textEl.equation.imageHeight = svgHeight;
                  textEl.equation.imageWidth = svgWidth;
                  textEl.equation.id = equationId;
          
                  equationSVGList.push({
                    id: equationId,
                    svg: svgEl.outerHTML,
                    height: svgHeight,
                    width: svgWidth,
                  });
                }
              });
            });
          
            // 非本地預覽的時候進行公式轉圖片并上傳 CDN(本地環境由于跨域無法上傳 CDN)。
            if (!isPreview) {
              OSS 上傳配置...
              // 公式 svg 轉圖片文件然后上傳 OSS。
              const res = await allSvgsToImgThenUpload(equationSVGList);
              equationElementList.forEach(element => {
                從res中找到當前公式元素對應的圖片,放入element.equation.imageUrl中
              });
            }
          };

          我們先找出所有文檔塊中的內聯公式,將其轉換為svg,存儲到公式塊中。如果當前是發送模式,不是預覽模式,我們就做進一步處理,使用allSvgsToImgThenUpload 將svg再轉化為圖片的CDN地址,此處的allSvgsToImgThenUpload方法讓我們并行處理所有的公式圖片,具體如下:

          function allSvgsToImgThenUpload(svgObjList: SvgObj[]) {
            // 將每個 SVG 字符串映射到轉換函數的調用上。
            const conversionPromises = svgObjList.map(svgObj => svgToImgThenUpload(svgObj));
          
            // 使用 Promise.all 等待所有圖片完成轉換和上傳。
            return Promise.all(conversionPromises);
          }

          核心的svgToImgThenUpload方法如下,它負責將svg轉化為圖片,并上傳CDN:

          
          /** svg 轉圖片,并上傳到 OSS。*/
          function svgToImgThenUpload(svgObj: SvgObj): Promise<{ id: string; url: string }> {
            return new Promise((resolve, reject) => {
              const { width, height, id } = svgObj;
              const svgString = svgObj.svg;
              if (!width || !height) {
                reject(`公式svg大小獲取失敗: ${id}`);
                return;
              }
          
              // 生成 svg 的 base64 編碼。
              const encodedString = encodeURIComponent(svgString).replace(/'/g, '%27').replace(/"/g, '%22');
              const dataUrl = 'data:image/svg+xml,' + encodedString;
          
              // 使用 canvas 渲染 svg 并轉為圖片。
              const image = new Image();
              image.onload = () => {
                const canvas = document.createElement('canvas');
                // 為了保證圖片清晰,渲染使用三倍寬高,實際大小使用兩倍寬高。
                canvas.width = width * 3;
                canvas.height = height * 3;
                canvas.style.width = `${width * 2}px`;
                canvas.style.height = `${height * 2}px`;
                const ctx = canvas.getContext('2d');
          
                ctx && ctx.drawImage(image, 0, 0, width * 3, height * 3);
                // 將 canvas 內容導出為 Blob。
                canvas.toBlob(async blob => {
                  創建 File 對象并上傳 CDN,返回 CDN 鏈接;
                }, 'image/png');
              };
          
              image.onerror = reject;
              image.src = dataUrl;
            });
          }

          為了保證圖片清晰,渲染使用三倍寬高,實際大小使用兩倍寬高。

          至此,我們讓公式塊帶上了圖片CDN地址。在發送時交給后端,轉為郵件附件,即可正常顯示了。

          • 最終呈現效果

          五、向前一步

          好在最終我們克服了重重困難,終于來到了轉譯工具升級的Showcase環節。之前有提到我們有fallbackRenderer,主要用于針對未識別或者未支持的文檔塊,渲染其默認提示,最初我們渲染的效果只是一個簡單的提示,比如:【畫板暫不支持解析】這樣的文案提示。

          但是我們很快發現:1. 這些提示并不明顯,可以做一個類似Antd Alert的提示;2. 在發送時要過濾掉這些提示,因為是無效信息;3. 在預覽時需要讓用戶能夠看到實際的發送效果,需要有開關能隱藏這些提示;4. 發送時存在這些不支持的塊時,需要攔截提示用戶是否去調整文檔內容,以達到信息更全效果更好的發送效果。往往是這些細枝末節的體驗與引導,能夠真正抓住用戶的心,讓用戶覺得這個轉譯工具是真的貼心、好用。

          因此,我們快速增加了這些具體的引導與提示優化,具體效果如下:

          六、大功告成

          經過這一番波折,我們最終成功地將飛書云文檔轉譯為兼容大多數客戶端的HTML郵件。這不僅僅是一項技術上的挑戰,更是一次心態和耐心的考驗。

          在這個過程中,我們深刻體會到在前端開發中,面對各種瀏覽器和客戶端的不一致性時,需要的不僅僅是技術能力,還需要靈活應變和堅持不懈的精神。希望本文能為同樣遇到這些問題的開發者提供一些思路和幫助。

          未來,我們還將繼續優化我們的解決方案,并探索更多高效的方法,期待與大家分享更多經驗。如果有任何問題或建議,歡迎在評論區留言討論!

          感謝閱讀!


          引用:

          • https://open.feishu.cn/document/server-docs/docs/docs/docx-v1/document/list
          • https://github.com/facebook/react/blob/81d4ee9ca5c405dce62f64e61506b8e155f38d8d/packages/react-dom-bindings/src/shared/CSSProperty.js#L8-L57


          *文/ Nicolas、Asher

          本文屬得物技術原創,未經得物技術許可嚴禁轉載,否則依法追究法律責任!


          者: 夢里夢中夢

          轉發鏈接:https://mp.weixin.qq.com/s/6J0uJKaC4SPlt2h7oeSP-Q

          小夕:https://juejin.im/post/5cab0c45f265da2513734390

          1. 基本類型有哪幾種?null 是對象嗎?基本數據類型和復雜數據類型存儲有什么區別?

          • 基本類型有6種,分別是undefined,null,bool,string,number,symbol(ES6新增)。
          • 雖然 typeof null 返回的值是 object,但是null不是對象,而是基本數據類型的一種。
          • 基本數據類型存儲在棧內存,存儲的是值。
          • 復雜數據類型的值存儲在堆內存,地址(指向堆中的值)存儲在棧內存。當我們把對象賦值給另外一個變量的時候,復制的是地址,指向同一塊內存空間,當其中一個對象改變時,另一個對象也會變化。

          2. typeof 是否正確判斷類型? instanceof呢? instanceof 的實現原理是什么?

          首先 typeof 能夠正確的判斷基本數據類型,但是除了 null, typeof null輸出的是對象。

          但是對象來說,typeof 不能正確的判斷其類型, typeof 一個函數可以輸出 'function',而除此之外,輸出的全是 object,這種情況下,我們無法準確的知道對象的類型。

          instanceof可以準確的判斷復雜數據類型,但是不能正確判斷基本數據類型。

          instanceof 是通過原型鏈判斷的,A instanceof B, 在A的原型鏈中層層查找,是否有原型等于B.prototype,如果一直找到A的原型鏈的頂端(null;即Object.__proto__.__proto__),仍然不等于B.prototype,那么返回false,否則返回true.

          instanceof的實現代碼:

          // L instanceof R
          function instance_of(L, R) {//L 表示左表達式,R 表示右表達式
           var O = R.prototype;// 取 R 的顯式原型
           L = L.__proto__; // 取 L 的隱式原型
           while (true) { 
           if (L === null) //已經找到頂層
           return false; 
           if (O === L) //當 O 嚴格等于 L 時,返回 true
           return true; 
           L = L.__proto__; //繼續向上一層原型鏈查找
           } 
          }
          

          3. for of , for in 和 forEach,map 的區別。

          • for...of循環:具有 iterator 接口,就可以用for...of循環遍歷它的成員(屬性值)。for...of循環可以使用的范圍包括數組、Set 和 Map 結構、某些類似數組的對象、Generator 對象,以及字符串。for...of循環調用遍歷器接口,數組的遍歷器接口只返回具有數字索引的屬性。對于普通的對象,for...of結構不能直接使用,會報錯,必須部署了 Iterator 接口后才能使用。可以中斷循環。
          • for...in循環:遍歷對象自身的和繼承的可枚舉的屬性, 不能直接獲取屬性值??梢灾袛嘌h。
          • forEach: 只能遍歷數組,不能中斷,沒有返回值(或認為返回值是undefined)。
          • map: 只能遍歷數組,不能中斷,返回值是修改后的數組。

          PS: Object.keys():返回給定對象所有可枚舉屬性的字符串數組。

          關于forEach是否會改變原數組的問題,有些小伙伴提出了異議,為此我寫了代碼測試了下(注意數組項是復雜數據類型的情況)。 除了forEach之外,map等API,也有同樣的問題。

          let arry = [1, 2, 3, 4];
          arry.forEach((item) => {
           item *= 10;
          });
          console.log(arry); //[1, 2, 3, 4]
          arry.forEach((item) => {
           arry[1] = 10; //直接操作數組
          });
          console.log(arry); //[ 1, 10, 3, 4 ]
          let arry2 = [
           { name: "Yve" },
           { age: 20 }
          ];
          arry2.forEach((item) => {
           item.name = 10;
          });
          console.log(arry2);//[ { name: 10 }, { age: 20, name: 10 } ]
          

          如還不了解 iterator 接口或 for...of, 請先閱讀ES6文檔: Iterator 和 for...of 循環

          更多細節請戳: github.com/YvetteLau/B…


          4. 如何判斷一個變量是不是數組?

          • 使用 Array.isArray 判斷,如果返回 true, 說明是數組
          • 使用 instanceof Array 判斷,如果返回true, 說明是數組
          • 使用 Object.prototype.toString.call 判斷,如果值是 [object Array], 說明是數組
          • 通過 constructor 來判斷,如果是數組,那么 arr.constructor === Array. (不準確,因為我們可以指定 obj.constructor = Array)
          function fn() {
           console.log(Array.isArray(arguments)); //false; 因為arguments是類數組,但不是數組
           console.log(Array.isArray([1,2,3,4])); //true
           console.log(arguments instanceof Array); //fasle
           console.log([1,2,3,4] instanceof Array); //true
           console.log(Object.prototype.toString.call(arguments)); //[object Arguments]
           console.log(Object.prototype.toString.call([1,2,3,4])); //[object Array]
           console.log(arguments.constructor === Array); //false
           arguments.constructor = Array;
           console.log(arguments.constructor === Array); //true
           console.log(Array.isArray(arguments)); //false
          }
          fn(1,2,3,4);
          

          5. 類數組和數組的區別是什么?

          類數組:

          1)擁有length屬性,其它屬性(索引)為非負整數(對象中的索引會被當做字符串來處理);

          2)不具有數組所具有的方法;

          類數組是一個普通對象,而真實的數組是Array類型。

          常見的類數組有: 函數的參數 arguments, DOM 對象列表(比如通過 document.querySelectorAll 得到的列表), jQuery 對象 (比如 $("div")).

          類數組可以轉換為數組:

          //第一種方法
          Array.prototype.slice.call(arrayLike, start);
          //第二種方法
          [...arrayLike];
          //第三種方法:
          Array.from(arrayLike);
          

          PS: 任何定義了遍歷器(Iterator)接口的對象,都可以用擴展運算符轉為真正的數組。

          Array.from方法用于將兩類對象轉為真正的數組:類似數組的對象(array-like object)和可遍歷(iterable)的對象。


          6. == 和 === 有什么區別?

          === 不需要進行類型轉換,只有類型相同并且值相等時,才返回 true.

          == 如果兩者類型不同,首先需要進行類型轉換。具體流程如下:

          1. 首先判斷兩者類型是否相同,如果相等,判斷值是否相等.
          2. 如果類型不同,進行類型轉換
          3. 判斷比較的是否是 null 或者是 undefined, 如果是, 返回 true .
          4. 判斷兩者類型是否為 string 和 number, 如果是, 將字符串轉換成 number
          5. 判斷其中一方是否為 boolean, 如果是, 將 boolean 轉為 number 再進行判斷
          6. 判斷其中一方是否為 object 且另一方為 string、number 或者 symbol , 如果是, 將 object 轉為原始類型再進行判斷
          let person1 = {
           age: 25
          }
          let person2 = person1;
          person2.gae = 20;
          console.log(person1 === person2); //true,注意復雜數據類型,比較的是引用地址
          

          思考: [] == ![]

          我們來分析一下: [] == ![] 是true還是false?

          1. 首先,我們需要知道 ! 優先級是高于 == (更多運算符優先級可查看: 運算符優先級)
          2. ![] 引用類型轉換成布爾值都是true,因此![]的是false
          3. 根據上面的比較步驟中的第五條,其中一方是 boolean,將 boolean 轉為 number 再進行判斷,false轉換成 number,對應的值是 0.
          4. 根據上面比較步驟中的第六條,有一方是 number,那么將object也轉換成Number,空數組轉換成數字,對應的值是0.(空數組轉換成數字,對應的值是0,如果數組中只有一個數字,那么轉成number就是這個數字,其它情況,均為NaN)
          5. 0 == 0; 為true

          7. ES6中的class和ES5的類有什么區別?

          1. ES6 class 內部所有定義的方法都是不可枚舉的;
          2. ES6 class 必須使用 new 調用;
          3. ES6 class 不存在變量提升;
          4. ES6 class 默認即是嚴格模式;
          5. ES6 class 子類必須在父類的構造函數中調用super(),這樣才有this對象;ES5中類繼承的關系是相反的,先有子類的this,然后用父類的方法應用在this上。

          8. 數組的哪些API會改變原數組?

          修改 原數組的API有:

          splice/reverse/fill/copyWithin/sort/push/pop/unshift/shift

          不修改 原數組的API有:

          slice/map/forEach/every/filter/reduce/entries/find

          注: 數組的每一項是簡單數據類型,且未直接操作數組的情況下(稍后會對此題重新作答)。


          9. let、const 以及 var 的區別是什么?

          • let 和 const 定義的變量不會出現變量提升,而 var 定義的變量會提升。
          • let 和 const 是JS中的塊級作用域
          • let 和 const 不允許重復聲明(會拋出錯誤)
          • let 和 const 定義的變量在定義語句之前,如果使用會拋出錯誤(形成了暫時性死區),而 var 不會。
          • const 聲明一個只讀的常量。一旦聲明,常量的值就不能改變(如果聲明是一個對象,那么不能改變的是對象的引用地址)

          10. 在JS中什么是變量提升?什么是暫時性死區?

          變量提升就是變量在聲明之前就可以使用,值為undefined。

          在代碼塊內,使用 let/const 命令聲明變量之前,該變量都是不可用的(會拋出錯誤)。這在語法上,稱為“暫時性死區”。暫時性死區也意味著 typeof 不再是一個百分百安全的操作。

          typeof x; // ReferenceError(暫時性死區,拋錯)
          let x;
          復制代碼
          typeof y; // 值是undefined,不會報錯
          

          暫時性死區的本質就是,只要一進入當前作用域,所要使用的變量就已經存在了,但是不可獲取,只有等到聲明變量的那一行代碼出現,才可以獲取和使用該變量。


          11. 如何正確的判斷this? 箭頭函數的this是什么?

          this的綁定規則有四種:默認綁定,隱式綁定,顯式綁定,new綁定.

          1. 函數是否在 new 中調用(new綁定),如果是,那么 this 綁定的是新創建的對象【前提是構造函數中沒有返回對象或者是function,否則this指向返回的對象/function】。
          2. 函數是否通過 call,apply 調用,或者使用了 bind (即硬綁定),如果是,那么this綁定的就是指定的對象。
          3. 函數是否在某個上下文對象中調用(隱式綁定),如果是的話,this 綁定的是那個上下文對象。一般是 obj.foo()
          4. 如果以上都不是,那么使用默認綁定。如果在嚴格模式下,則綁定到 undefined,否則綁定到全局對象。
          5. 如果把 null 或者 undefined 作為 this 的綁定對象傳入 call、apply 或者 bind, 這些值在調用時會被忽略,實際應用的是默認綁定規則。
          6. 箭頭函數沒有自己的 this, 它的this繼承于上一層代碼塊的this。

          測試下是否已經成功Get了此知識點(瀏覽器執行環境):

          var number = 5;
          var obj = {
           number: 3,
           fn1: (function () {
           var number;
           this.number *= 2;
           number = number * 2;
           number = 3;
           return function () {
           var num = this.number;
           this.number *= 2;
           console.log(num);
           number *= 3;
           console.log(number);
           }
           })()
          }
          var fn1 = obj.fn1;
          fn1.call(null);
          obj.fn1();
          console.log(window.number);
          


          12. 詞法作用域和this的區別。

          • 詞法作用域是由你在寫代碼時將變量和塊作用域寫在哪里來決定的
          • this 是在調用時被綁定的,this 指向什么,完全取決于函數的調用位置.

          13. 談談你對JS執行上下文棧和作用域鏈的理解。

          執行上下文就是當前 JavaScript 代碼被解析和執行時所在環境, JS執行上下文棧可以認為是一個存儲函數調用的棧結構,遵循先進后出的原則。

          • JavaScript執行在單線程上,所有的代碼都是排隊執行。
          • 一開始瀏覽器執行全局的代碼時,首先創建全局的執行上下文,壓入執行棧的頂部。
          • 每當進入一個函數的執行就會創建函數的執行上下文,并且把它壓入執行棧的頂部。當前函數執行-完成后,當前函數的執行上下文出棧,并等待垃圾回收。
          • 瀏覽器的JS執行引擎總是訪問棧頂的執行上下文。
          • 全局上下文只有唯一的一個,它在瀏覽器關閉時出棧。

          作用域鏈: 無論是 LHS 還是 RHS 查詢,都會在當前的作用域開始查找,如果沒有找到,就會向上級作用域繼續查找目標標識符,每次上升一個作用域,一直到全局作用域為止。


          14. 什么是閉包?閉包的作用是什么?閉包有哪些使用場景?

          閉包是指有權訪問另一個函數作用域中的變量的函數,創建閉包最常用的方式就是在一個函數內部創建另一個函數。

          閉包的作用有:

          1. 封裝私有變量
          2. 模仿塊級作用域(ES5中沒有塊級作用域)
          3. 實現JS的模塊

          15. call、apply有什么區別?call,aplly和bind的內部是如何實現的?

          call 和 apply 的功能相同,區別在于傳參的方式不一樣:

          • fn.call(obj, arg1, arg2, ...),調用一個函數, 具有一個指定的this值和分別地提供的參數(參數的列表)。
          • fn.apply(obj, [argsArray]),調用一個函數,具有一個指定的this值,以及作為一個數組(或類數組對象)提供的參數。

          call核心:

          • 將函數設為傳入參數的屬性
          • 指定this到函數并傳入給定參數執行函數
          • 如果不傳入參數或者參數為null,默認指向為 window / global
          • 刪除參數上的函數
          Function.prototype.call = function (context) {
           /** 如果第一個參數傳入的是 null 或者是 undefined, 那么指向this指向 window/global */
           /** 如果第一個參數傳入的不是null或者是undefined, 那么必須是一個對象 */
           if (!context) {
           //context為null或者是undefined
           context = typeof window === 'undefined' ? global : window;
           }
           context.fn = this; //this指向的是當前的函數(Function的實例)
           let rest = [...arguments].slice(1);//獲取除了this指向對象以外的參數, 空數組slice后返回的仍然是空數組
           let result = context.fn(...rest); //隱式綁定,當前函數的this指向了context.
           delete context.fn;
           return result;
          }
          //測試代碼
          var foo = {
           name: 'Selina'
          }
          var name = 'Chirs';
          function bar(job, age) {
           console.log(this.name);
           console.log(job, age);
          }
          bar.call(foo, 'programmer', 20);
          // Selina programmer 20
          bar.call(null, 'teacher', 25);
          // 瀏覽器環境: Chirs teacher 25; node 環境: undefined teacher 25
          

          apply:

          apply的實現和call很類似,但是需要注意他們的參數是不一樣的,apply的第二個參數是數組或類數組.

          Function.prototype.apply = function (context, rest) {
           if (!context) {
           //context為null或者是undefined時,設置默認值
           context = typeof window === 'undefined' ? global : window;
           }
           context.fn = this;
           let result;
           if(rest === undefined || rest === null) {
           //undefined 或者 是 null 不是 Iterator 對象,不能被 ...
           result = context.fn(rest);
           }else if(typeof rest === 'object') {
           result = context.fn(...rest);
           }
           delete context.fn;
           return result;
          }
          var foo = {
           name: 'Selina'
          }
          var name = 'Chirs';
          function bar(job, age) {
           console.log(this.name);
           console.log(job, age);
          }
          bar.apply(foo, ['programmer', 20]);
          // Selina programmer 20
          bar.apply(null, ['teacher', 25]);
          // 瀏覽器環境: Chirs programmer 20; node 環境: undefined teacher 25
          

          bind

          bind 和 call/apply 有一個很重要的區別,一個函數被 call/apply 的時候,會直接調用,但是 bind 會創建一個新函數。當這個新函數被調用時,bind() 的第一個參數將作為它運行時的 this,之后的一序列參數將會在傳遞的實參前傳入作為它的參數。

          Function.prototype.bind = function(context) {
           if(typeof this !== "function"){
           throw new TypeError("not a function");
           }
           let self = this;
           let args = [...arguments].slice(1);
           function Fn() {};
           Fn.prototype = this.prototype;
           let bound = function() {
           let res = [...args, ...arguments]; //bind傳遞的參數和函數調用時傳遞的參數拼接
           context = this instanceof Fn ? this : context || this;
           return self.apply(context, res);
           }
           //原型鏈
           bound.prototype = new Fn();
           return bound;
          }
          var name = 'Jack';
          function person(age, job, gender){
           console.log(this.name , age, job, gender);
          }
          var Yve = {name : 'Yvette'};
          let result = person.bind(Yve, 22, 'enginner')('female');	
          


          16. new的原理是什么?通過new的方式創建對象和通過字面量創建有什么區別?

          new:

          1. 創建一個新對象。
          2. 這個新對象會被執行[[原型]]連接。
          3. 屬性和方法被加入到 this 引用的對象中。并執行了構造函數中的方法.
          4. 如果函數沒有返回其他對象,那么this指向這個新對象,否則this指向構造函數中返回的對象。
          function new(func) {
           let target = {};
           target.__proto__ = func.prototype;
           let res = func.call(target);
           if (res && typeof(res) == "object" || typeof(res) == "function") {
           	return res;
           }
           return target;
          }
          

          字面量創建對象,不會調用 Object構造函數, 簡潔且性能更好;

          new Object() 方式創建對象本質上是方法調用,涉及到在proto鏈中遍歷該方法,當找到該方法后,又會生產方法調用必須的 堆棧信息,方法調用結束后,還要釋放該堆棧,性能不如字面量的方式。

          通過對象字面量定義對象時,不會調用Object構造函數。


          17. 談談你對原型的理解?

          在 JavaScript 中,每當定義一個對象(函數也是對象)時候,對象中都會包含一些預定義的屬性。其中每個函數對象都有一個prototype 屬性,這個屬性指向函數的原型對象。使用原型對象的好處是所有對象實例共享它所包含的屬性和方法。


          18. 什么是原型鏈?【原型鏈解決的是什么問題?】

          原型鏈解決的主要是繼承問題。

          每個對象擁有一個原型對象,通過 proto (讀音: dunder proto) 指針指向其原型對象,并從中繼承方法和屬性,同時原型對象也可能擁有原型,這樣一層一層,最終指向 null(Object.proptotype.__proto__ 指向的是null)。這種關系被稱為原型鏈 (prototype chain),通過原型鏈一個對象可以擁有定義在其他對象中的屬性和方法。

          構造函數 Parent、Parent.prototype 和 實例 p 的關系如下:(p.__proto__ === Parent.prototype)

          19. prototype 和 __proto__ 區別是什么?

          prototype是構造函數的屬性。

          __proto__ 是每個實例都有的屬性,可以訪問 [[prototype]] 屬性。

          實例的__proto__ 與其構造函數的prototype指向的是同一個對象。

          function Student(name) {
           this.name = name;
          }
          Student.prototype.setAge = function(){
           this.age=20;
          }
          let Jack = new Student('jack');
          console.log(Jack.__proto__);
          //console.log(Object.getPrototypeOf(Jack));;
          console.log(Student.prototype);
          console.log(Jack.__proto__ === Student.prototype);//true
          

          20. 使用ES5實現一個繼承?

          組合繼承(最常用的繼承方式)

          function SuperType(name) {
           this.name = name;
           this.colors = ['red', 'blue', 'green'];
          }
          SuperType.prototype.sayName = function() {
           console.log(this.name);
          }
          function SubType(name, age) {
           SuperType.call(this, name);
           this.age = age;
          }
          SubType.prototype = new SuperType();
          SubType.prototype.constructor = SubType;
          SubType.prototype.sayAge = function() {
           console.log(this.age);
          }
          
          

          其它繼承方式實現,可以參考《JavaScript高級程序設計》


          21. 什么是深拷貝?深拷貝和淺拷貝有什么區別?

          淺拷貝是指只復制第一層對象,但是當對象的屬性是引用類型時,實質復制的是其引用,當引用指向的值改變時也會跟著變化。

          深拷貝復制變量值,對于非基本類型的變量,則遞歸至基本類型變量后,再復制。深拷貝后的對象與原來的對象是完全隔離的,互不影響,對一個對象的修改并不會影響另一個對象。

          實現一個深拷貝:

          function deepClone(obj) { //遞歸拷貝
           if(obj === null) return null; //null 的情況
           if(obj instanceof RegExp) return new RegExp(obj);
           if(obj instanceof Date) return new Date(obj);
           if(typeof obj !== 'object') {
           //如果不是復雜數據類型,直接返回
           return obj;
           }
           /**
           * 如果obj是數組,那么 obj.constructor 是 [Function: Array]
           * 如果obj是對象,那么 obj.constructor 是 [Function: Object]
           */
           let t = new obj.constructor();
           for(let key in obj) {
           //如果 obj[key] 是復雜數據類型,遞歸
           t[key] = deepClone(obj[key]);
           }
           return t;
          }
          


          22. 防抖和節流的區別是什么?防抖和節流的實現。

          防抖和節流的作用都是防止函數多次調用。區別在于,假設一個用戶一直觸發這個函數,且每次觸發函數的間隔小于設置的時間,防抖的情況下只會調用一次,而節流的情況會每隔一定時間調用一次函數。

          防抖(debounce): n秒內函數只會執行一次,如果n秒內高頻事件再次被觸發,則重新計算時間

          function debounce(func, wait, immediate = true) {
           let timer;
           // 延遲執行函數
           const later = (context, args) => setTimeout(() => {
           timer = null;// 倒計時結束
           if (!immediate) {
           func.apply(context, args);
           //執行回調
           context = args = null;
           }
           }, wait);
           let debounced = function (...params) {
           let context = this;
           let args = params;
           if (!timer) {
           timer = later(context, args);
           if (immediate) {
           //立即執行
           func.apply(context, args);
           }
           } else {
           clearTimeout(timer);
           //函數在每個等待時延的結束被調用
           timer = later(context, args);
           }
           }
           debounced.cancel = function () {
           clearTimeout(timer);
           timer = null;
           };
           return debounced;
          };
          

          防抖的應用場景:

          • 每次 resize/scroll 觸發統計事件
          • 文本輸入的驗證(連續輸入文字后發送 AJAX 請求進行驗證,驗證一次就好)

          節流(throttle): 高頻事件在規定時間內只會執行一次,執行一次后,只有大于設定的執行周期后才會執行第二次。

          //underscore.js
          function throttle(func, wait, options) {
           var timeout, context, args, result;
           var previous = 0;
           if (!options) options = {};
           var later = function () {
           previous = options.leading === false ? 0 : Date.now() || new Date().getTime();
           timeout = null;
           result = func.apply(context, args);
           if (!timeout) context = args = null;
           };
           var throttled = function () {
           var now = Date.now() || new Date().getTime();
           if (!previous && options.leading === false) previous = now;
           var remaining = wait - (now - previous);
           context = this;
           args = arguments;
           if (remaining <= 0 || remaining > wait) {
           if (timeout) {
           clearTimeout(timeout);
           timeout = null;
           }
           previous = now;
           result = func.apply(context, args);
           if (!timeout) context = args = null;
           } else if (!timeout && options.trailing !== false) {
           // 判斷是否設置了定時器和 trailing
           timeout = setTimeout(later, remaining);
           }
           return result;
           };
           throttled.cancel = function () {
           clearTimeout(timeout);
           previous = 0;
           timeout = context = args = null;
           };
           return throttled;
          };
          

          函數節流的應用場景有:

          • DOM 元素的拖拽功能實現(mousemove)
          • 射擊游戲的 mousedown/keydown 事件(單位時間只能發射一顆子彈)
          • 計算鼠標移動的距離(mousemove)
          • Canvas 模擬畫板功能(mousemove)
          • 搜索聯想(keyup)
          • 監聽滾動事件判斷是否到頁面底部自動加載更多:給 scroll 加了 debounce 后,只有用戶停止滾動后,才會判斷是否到了頁面底部;如果是 throttle 的話,只要頁面滾動就會間隔一段時間判斷一次

          23. 取數組的最大值(ES5、ES6)

          // ES5 的寫法
          Math.max.apply(null, [14, 3, 77, 30]);
          // ES6 的寫法
          Math.max(...[14, 3, 77, 30]);
          // reduce
          [14,3,77,30].reduce((accumulator, currentValue)=>{
           return accumulator = accumulator > currentValue ? accumulator : currentValue
          });
          

          24. ES6新的特性有哪些?

          1. 新增了塊級作用域(let,const)
          2. 提供了定義類的語法糖(class)
          3. 新增了一種基本數據類型(Symbol)
          4. 新增了變量的解構賦值
          5. 函數參數允許設置默認值,引入了rest參數,新增了箭頭函數
          6. 數組新增了一些API,如 isArray / from / of 方法;數組實例新增了 entries(),keys() 和 values() 等方法
          7. 對象和數組新增了擴展運算符
          8. ES6 新增了模塊化(import/export)
          9. ES6 新增了 Set 和 Map 數據結構
          10. ES6 原生提供 Proxy 構造函數,用來生成 Proxy 實例
          11. ES6 新增了生成器(Generator)和遍歷器(Iterator)

          25. setTimeout倒計時為什么會出現誤差?

          setTimeout() 只是將事件插入了“任務隊列”,必須等當前代碼(執行棧)執行完,主線程才會去執行它指定的回調函數。要是當前代碼消耗時間很長,也有可能要等很久,所以并沒辦法保證回調函數一定會在 setTimeout() 指定的時間執行。所以, setTimeout() 的第二個參數表示的是最少時間,并非是確切時間。

          HTML5標準規定了 setTimeout() 的第二個參數的最小值不得小于4毫秒,如果低于這個值,則默認是4毫秒。在此之前。老版本的瀏覽器都將最短時間設為10毫秒。另外,對于那些DOM的變動(尤其是涉及頁面重新渲染的部分),通常是間隔16毫秒執行。這時使用 requestAnimationFrame() 的效果要好于 setTimeout();


          26. 為什么 0.1 + 0.2 != 0.3 ?

          0.1 + 0.2 != 0.3 是因為在進制轉換和進階運算的過程中出現精度損失。

          下面是詳細解釋:

          JavaScript使用 Number 類型表示數字(整數和浮點數),使用64位表示一個數字。

          圖片說明:

          • 第0位:符號位,0表示正數,1表示負數(s)
          • 第1位到第11位:儲存指數部分(e)
          • 第12位到第63位:儲存小數部分(即有效數字)f

          計算機無法直接對十進制的數字進行運算, 需要先對照 IEEE 754 規范轉換成二進制,然后對階運算。

          1.進制轉換

          0.1和0.2轉換成二進制后會無限循環

          0.1 -> 0.0001100110011001...(無限循環)
          0.2 -> 0.0011001100110011...(無限循環)
          

          但是由于IEEE 754尾數位數限制,需要將后面多余的位截掉,這樣在進制之間的轉換中精度已經損失。

          2.對階運算

          由于指數位數不相同,運算時需要對階運算 這部分也可能產生精度損失。

          按照上面兩步運算(包括兩步的精度損失),最后的結果是

          0.0100110011001100110011001100110011001100110011001100

          結果轉換成十進制之后就是 0.30000000000000004。

          27. promise 有幾種狀態, Promise 有什么優缺點 ?

          promise有三種狀態: fulfilled, rejected, pending.

          Promise 的優點:

          1. 一旦狀態改變,就不會再變,任何時候都可以得到這個結果
          2. 可以將異步操作以同步操作的流程表達出來,避免了層層嵌套的回調函數

          Promise 的缺點:

          1. 無法取消 Promise
          2. 當處于pending狀態時,無法得知目前進展到哪一個階段

          28. Promise構造函數是同步還是異步執行,then中的方法呢 ?promise如何實現then處理 ?

          Promise的構造函數是同步執行的。then 中的方法是異步執行的。


          29. Promise和setTimeout的區別 ?

          Promise 是微任務,setTimeout 是宏任務,同一個事件循環中,promise.then總是先于 setTimeout 執行。


          30. 如何實現 Promise.all ?

          要實現 Promise.all,首先我們需要知道 Promise.all 的功能:

          1. 如果傳入的參數是一個空的可迭代對象,那么此promise對象回調完成(resolve),只有此情況,是同步執行的,其它都是異步返回的。
          2. 如果傳入的參數不包含任何 promise,則返回一個異步完成. promises 中所有的promise都“完成”時或參數中不包含 promise 時回調完成。
          3. 如果參數中有一個promise失敗,那么Promise.all返回的promise對象失敗
          4. 在任何情況下,Promise.all 返回的 promise 的完成狀態的結果都是一個數組
          Promise.all = function (promises) {
           return new Promise((resolve, reject) => {
           let index = 0;
           let result = [];
           if (promises.length === 0) {
           resolve(result);
           } else {
           function processValue(i, data) {
           result[i] = data;
           if (++index === promises.length) {
           resolve(result);
           }
           }
           for (let i = 0; i < promises.length; i++) {
           //promises[i] 可能是普通值
           Promise.resolve(promises[i]).then((data) => {
           processValue(i, data);
           }, (err) => {
           reject(err);
           return;
           });
           }
           }
           });
          }
          


          31.如何實現 Promise.finally ?

          不管成功還是失敗,都會走到finally中,并且finally之后,還可以繼續then。并且會將值原封不動的傳遞給后面的then.

          Promise.prototype.finally = function (callback) {
           return this.then((value) => {
           return Promise.resolve(callback()).then(() => {
           return value;
           });
           }, (err) => {
           return Promise.resolve(callback()).then(() => {
           throw err;
           });
           });
          }
          

          32. 什么是函數柯里化?實現 sum(1)(2)(3) 返回結果是1,2,3之和。

          函數柯里化是把接受多個參數的函數變換成接受一個單一參數(最初函數的第一個參數)的函數,并且返回接受余下的參數而且返回結果的新函數的技術。

          function sum(a) {
           return function(b) {
           return function(c) {
           return a+b+c;
           }
           }
          }
          console.log(sum(1)(2)(3)); // 6
          

          引申:實現一個curry函數,將普通函數進行柯里化:


          主站蜘蛛池模板: 国产成人无码AV一区二区| 好爽毛片一区二区三区四| 免费高清av一区二区三区| 亚洲变态另类一区二区三区| 无码少妇一区二区三区| 亚洲一区二区电影| 中文字幕一区在线观看视频| 性色av无码免费一区二区三区| 亚洲国产精品一区二区三区在线观看| 国产精品香蕉一区二区三区| 国产精品一区二区香蕉| 中文无码精品一区二区三区 | 国产福利91精品一区二区| 国产精品高清视亚洲一区二区| 国产乱码精品一区二区三区四川人| 国产一区二区三区免费看| 国产成人精品一区二区三区无码 | 日本片免费观看一区二区| 乱码人妻一区二区三区| 亚洲国产综合无码一区二区二三区| 久草新视频一区二区三区| 亚洲AV无码一区二区三区网址| 无码精品前田一区二区| 精品无码国产一区二区三区AV| 激情久久av一区av二区av三区| 熟女少妇精品一区二区| 无码一区二区波多野结衣播放搜索 | 人妻免费一区二区三区最新| 亚洲综合一区无码精品| 少妇无码AV无码一区| 伊人色综合网一区二区三区| 自慰无码一区二区三区| 正在播放国产一区| 精品一区二区三区免费毛片爱 | 蜜桃臀无码内射一区二区三区| 日韩一区二区三区视频| 亚洲熟妇成人精品一区| 无码人妻久久久一区二区三区| 国产伦精品一区二区三区四区| 国产经典一区二区三区蜜芽| 国产一区麻豆剧传媒果冻精品|