Nuggets (Bytedance) MD editor source code analysis

Written in the beginning

  • As a former rich text editor developer, I was intrigued, so this article came into being

  • First findgithubThe source code,https://github.com/bytedance/bytemd“, and then cloned, and it started

Recently, I wrote a front-end architecture 100 sets, which will be updated gradually. Please don’t worry. At present, we are revising and deliberating the content repeatedly

The official start of the

  • My own computer environmentArmarchitectureMacThe M1 chip
  • The environment,nvmControl multiplenode.jsVersion, computer requires global installationpnpmFor dependency management (here bytedance is usedpnpmManaging dependencies)

If you don’t understand PNPM, more food, that’s ok, I have the article: https://juejin.cn/post/6932046455733485575

  • Install project dependencies (this project is usedlernaManaging dependencies) :
NVM install 12.17 NPM I PNPM -g PNPM ICopy the code
  • Debug editor source code locally in the project:
NPM Link or YALCCopy the code

If you compare food, won’t this two ways, that’s ok, I have the article: https://mp.weixin.qq.com/s/t6u6snq_S3R0X7b1MbvDVA, a word not flip open to the public, there are. My front-end architecture is going to be in 100

React version source code parsing

React how does React work

  • Let’s start with styles:
import 'bytemd/dist/index.min.css';
Copy the code
  • Reintroduce components:
import { Editor, Viewer } from '@bytemd/react';
import gfm from '@bytemd/plugin-gfm';

const plugins = [
  gfm(),
  // Add more plugins here
];

const App = () => {
  const [value, setValue] = useState('');

  return (
    <Editor
      value={value}
      plugins={plugins}
      onChange={(v) => {
        setValue(v);
      }}
    />
  );
};
Copy the code
  • fromEditorThe component of
import React, { useEffect, useRef } from 'react'; import * as bytemd from 'bytemd'; export interface EditorProps extends bytemd.EditorProps { onChange? (value: string): void; } export const Editor: React.FC<EditorProps> = ({ children, onChange, ... props }) => { const ed = useRef<bytemd.Editor>(); const el = useRef<HTMLDivElement>(null); useEffect(() => { if (! el.current) return; const editor = new bytemd.Editor({ target: el.current, props, }); editor.$on('change', (e: CustomEvent<{ value: string }>) => { onChange? .(e.detail.value); }); ed.current = editor; return () => { editor.$destroy(); }; } []); useEffect(() => { // TODO: performance ed.current? .$set(props); }, [props]); return <div ref={el}></div>; };Copy the code
  • Found out that everything came from:bytemdThis library, so we go to find its source ~
// <reference types="svelte" /> import Editor from './editor.svelte'; import Viewer from './viewer.svelte'; export { Editor, Viewer }; export * from './utils'; export * from './types';Copy the code

Dude, this Editor is written in sveltejs at https://www.sveltejs.cn/

  • React and Vue are both Runtime-based frameworks. The runtime-based framework itself is also packaged into the final bundle.js and sent to the user’s browser.
  • When the user performs various actions on your page to change the state of the component, the Framework Runtime calculates (diff) which DOM nodes need to be updated based on the new component state to update the view
  • The smallest Vue has 58K and React has 97.5K. In other words, if you use React as a framework for development, even if your business code is simple, your first screen bundle size should start at 100K. Of course, 100K is not very large, but everything is relative. Compared with large management systems, 100K is certainly not a big deal. However, for those applications that are sensitive to the loading time on the first screen (such as Taobao and jingdong home page), 100K bundle size will really affect the user experience in some bad network environment or mobile phone. So how do you reduce the runtime code size of your framework? The most effective way to reduce runtime code is to not use the Runtime at all

So you can see that the Nuggets (Bytedance) take performance very seriously

What is a Svelte?

  • Svelte is a compilation framework written by RollupJs author Rich Harris. If you are not familiar with RollupJs, Svelte is a packaging tool similar to Webpack. The Svelte framework has the following features: Similar to modern Web frameworks like React and Vue, it allows developers to quickly develop Web applications with a smooth user experience. It does not use the Virtual DOM, nor is it a runtime library. Based on the idea of Compiler as Framework, it converts your application to native DOM manipulation at compile time

This article writes very comprehensive, about Svelte, https://zhuanlan.zhihu.com/p/97825481, because this article focuses on the source code, not the environment, not the underlying framework is introduced, unquestioning, interested to see the article ~

The editor is divided into several areas

  • First, the title area, the input field, what else to say
  • Next, it should be all about inserting content into the editor (important)
  • The ones on the right that change some style and layout can be ignored
  • The content area on the left is the editing area (emphasis)
  • Content preview area on the right (key)

First, a wave of performance testing

  • So here I’m copying like crazy, pasting like crazy, and there’s an anti-shake optimization, and it should be a certain amount of time, and then I’ll render it after I let go of pasting. (refer to theThe React of FiberThoughts)
  • Found in the editor source codecodemirrorThis library, all right. Source code from this editor, let’s go to the editor source code. Then came to thegithub, clone source code:https://github.com/codemirror/CodeMirror

Editor this thing, to find suitable source of secondary packaging, is a good thing, I just work that in order to write a WeChat this desktop client editor (it is cross-platform, Electron), that almost two months passed away, reconstructed the N times, in the N time plan, learn all the React source again, by the way, finally achieved by hand using native

Find the CodeMirror entry file

import { CodeMirror } from "./edit/main.js"

export default CodeMirror

Copy the code

How does CodeMirror work

var editor = CodeMirror.fromTextArea(document.getElementById("editorArea"), { lineNumbers: MatchBrackets: true, // brackets match mode: "text/x-c++ SRC ", // c++ indentUnit:4, // indentWithTabs: SmartIndent: true, // smartIndent: true, // Auto indent, set whether to auto indent based on context (the same amount of indentation as the previous line). The default is true. StyleActiveLine: true, // Current line background highlight theme: 'midnight', // editor theme}); editor.setSize('600px','400px'); // Set the code box sizeCopy the code
  • So let’s find this methodfromTextAreaMethod, which is the entry point to the entire source code

Insert a node in front of the editor’s parent, then pass in the CodeMirror function

  • To find theCodeMirrorThe real source code, found is a function

  • This function is the essence of the source code withnode.jsSource code is a bit like, prototype chain related to use more
The CodeMirror function if (! (this instanceof CodeMirror)) {return new CodeMirror(place, options) }Copy the code

Make sure the constructor calls this to

  • Then make sureoptionsIt’s going to be an object
 this.options = options = options ? copyObj(options) : {}
Copy the code
  • (copyObjIs a shallow clone + merge two object method)
export function copyObj(obj, target, overwrite) { if (! target) target = {} for (let prop in obj) if (obj.hasOwnProperty(prop) && (overwrite ! == false || ! target.hasOwnProperty(prop))) target[prop] = obj[prop] return target }Copy the code
  • The function then internally merges the passed parameters with the default configuration items
copyObj(defaults, options, false)
Copy the code

So the options passed in have the default configuration of the option ~

  • Then getvalue, the editor is generally two-way data binding ~(that is, exposing the onchange method, using the component to maintain the value externally, and modifying the value through the parameters of the onchange callback).
  let doc = options.value
  if (typeof doc == "string") doc = new Doc(doc, options.mode, null, options.lineSeparator, options.direction)
  else if (options.mode) doc.modeOption = options.mode
  this.doc = doc
Copy the code
  • The above means to format the value passed in. That’s my idea. Let’s take a lookDocThe source code
let Doc = function(text, mode, firstLine, lineSep, direction) { if (! (this instanceof Doc)) return new Doc(text, mode, firstLine, lineSep, direction) if (firstLine == null) firstLine = 0 BranchChunk.call(this, [new LeafChunk([new Line("", null)])]) this.first = firstLine this.scrollTop = this.scrollLeft = 0 this.cantEdit = false this.cleanGeneration = 1 this.modeFrontier = this.highlightFrontier = firstLine let start = Pos(firstLine, 0) this.sel = simpleSelection(start) this.history = new History(null) this.id = ++nextDocId this.modeOption = mode this.lineSep = lineSep this.direction = (direction == "rtl") ? "rtl" : "ltr" this.extend = false if (typeof text == "string") text = this.splitLines(text) updateDoc(this, {from: start, to: start, text: text}) setSelection(this, simpleSelection(start), sel_dontScroll) }Copy the code
  • Ok, we are beginning to approach the implementation of the editor, to reduce complexity, we will only look at the source code of a few key points
  • The editor uses onefirstLineRecord the starting position of the first line, default is 0, other default values. fromPOSIn the beginning, this is more important.
Let start = Pos(firstLine, 0)  // A Pos instance represents a position within the text. export function Pos(line, ch, sticky = null) { if (! (this instanceof Pos)) return new Pos(line, ch, sticky) this.line = line this.ch = ch this.sticky = sticky }Copy the code

This function, I kind of know what it means, but I don’t know what it means yet. So let’s leave it there, knowing that it records position (but not what position)

  • The following is
This.sel = simpleSelection(start) //simpleSelection method export function simpleSelection(anchor, head) { return new Selection([new Range(anchor, head || anchor)], 0) }Copy the code
  • SimpleSelection methodBy creating a new oneRangeCursor object, and return oneSelectionObject, which is how most front-end editors have worked for the last decade

So let’s talk about Range and Select objects

  • A Selection object represents a collection of user-selected ranges. Typically, it contains only one region, accessed as follows:
window.getSelection();
Copy the code

The blue part below is the return value of this method

  • The Range interface represents a document fragment that contains nodes and portions of text nodes.

You can create a Range using the Document.createRange method of the Document object or get a Range using the getRangeAt method of the Selection object. Alternatively, Range can be obtained from the Document object’s constructor Range().

  • createdSelectObject, then we need to determine if the value passed in is a string, then we need to do a processing
If (typeof text == "string") text = this.splitLines(text) //splitLines function(str) { if (this.lineSep) return str.split(this.lineSep) return splitLinesAuto(str) },Copy the code

The split() method splits a String into an array of substrings using the specified delimiter String, with a specified split String determining the location of each split.

  • If you want to split the value at the specified location, you need to split the value at the specified location, otherwise you need to split the value at the specified locationsplitLinesAutoProcessing (here for compatibilityIEThe inside of the"".split ~)
export let splitLinesAuto = "\n\nb".split(/\n/).length ! = 3? string => { let pos = 0, result = [], l = string.length while (pos <= l) { let nl = string.indexOf("\n", pos) if (nl == -1) nl = string.length let line = string.slice(pos, string.charAt(nl - 1) == "\r" ? nl - 1 : nl) let rt = line.indexOf("\r") if (rt ! = -1) { result.push(line.slice(0, rt)) pos += rt + 1 } else { result.push(line) pos = nl + 1 } } return result } : string => string.split(/\r\n? |\n/)Copy the code
  • After the data is finished, the editor content needs to be updated
 updateDoc(this, {from: start, to: 
 start, text: text})
Copy the code

Write here, I have a little doubt of life, I thought clone source code down, half an hour to fix. As a result, the code is extremely difficult to read. Like the Node.js source code, the methods and properties are mostly mounted on the prototype, especially since the link length is so long that you can understand how difficult it is to write a good article. Said many are tears, fortunately, my coin circle yesterday soha bottom, today. I’m gonna finish it tonight anyway, okay

  • Then look atupdateDocThis method, as you can see, passes in the instance object with the starting point, ending point, and text. (This method is really ugly)

I’m going to define a couple of functions, but I’m not going to look at them

// Perform a change on the document data structure.
export function updateDoc(doc, change, markedSpans, estimateHeight) {
  function spansFor(n) {return markedSpans ? markedSpans[n] : null}
  function update(line, text, spans) {
    updateLine(line, text, spans, estimateHeight)
    signalLater(line, "change", line, change)
  }
  function linesFor(start, end) {
    let result = []
    for (let i = start; i < end; ++i)
      result.push(new Line(text[i], spansFor(i), estimateHeight))
    return result
  }
  ...

Copy the code

Then we get the external data, such as from,to,text, the first line, the last content, and so on


  let from = change.from, to = change.to, text = change.text
  let firstLine = getLine(doc, from.line), lastLine = getLine(doc, to.line)
  let lastText = lst(text), lastSpans = spansFor(text.length - 1), nlines = to.line - from.line

Copy the code
  • Next, the actual processing logic to handle changes in the editor’s content, take a look, directly out of the code first
 // Adjust the line structure
  if (change.full) {
    doc.insert(0, linesFor(0, text.length))
    doc.remove(text.length, doc.size - text.length)
  } else if (isWholeLineUpdate(doc, change)) {
    // This is a whole-line replace. Treated specially to make
    // sure line objects move the way they are supposed to.
    let added = linesFor(0, text.length - 1)
    update(lastLine, lastLine.text, lastSpans)
    if (nlines) doc.remove(from.line, nlines)
    if (added.length) doc.insert(from.line, added)
  } else if (firstLine == lastLine) {
    if (text.length == 1) {
      update(firstLine, firstLine.text.slice(0, from.ch) + lastText + firstLine.text.slice(to.ch), lastSpans)
    } else {
      let added = linesFor(1, text.length - 1)
      added.push(new Line(lastText + firstLine.text.slice(to.ch), lastSpans, estimateHeight))
      update(firstLine, firstLine.text.slice(0, from.ch) + text[0], spansFor(0))
      doc.insert(from.line + 1, added)
    }
  } else if (text.length == 1) {
    update(firstLine, firstLine.text.slice(0, from.ch) + text[0] + lastLine.text.slice(to.ch), spansFor(0))
    doc.remove(from.line + 1, nlines)
  } else {
    update(firstLine, firstLine.text.slice(0, from.ch) + text[0], spansFor(0))
    update(lastLine, lastText + lastLine.text.slice(to.ch), lastSpans)
    let added = linesFor(1, text.length - 1)
    if (nlines > 1) doc.remove(from.line + 1, nlines - 1)
    doc.insert(from.line + 1, added)
  }

  signalLater(doc, "change", doc, change)
Copy the code

The code above means:

  • If it is a row structure adjustment, then do it
 doc.insert(0, linesFor(0, text.length))
 doc.remove(text.length, doc.size - text.length)
Copy the code
  • If it’s an entire row, then
 else if (isWholeLineUpdate(doc, change)) {
    // This is a whole-line replace. Treated specially to make
    // sure line objects move the way they are supposed to.
    let added = linesFor(0, text.length - 1)
    update(lastLine, lastLine.text, lastSpans)
    if (nlines) doc.remove(from.line, nlines)
    if (added.length) doc.insert(from.line, added)
  } 
Copy the code

If the first row is equal to the last row

else if (firstLine == lastLine) {
    if (text.length == 1) {
      update(firstLine, firstLine.text.slice(0, from.ch) + lastText + firstLine.text.slice(to.ch), lastSpans)
    } else {
      let added = linesFor(1, text.length - 1)
      added.push(new Line(lastText + firstLine.text.slice(to.ch), lastSpans, estimateHeight))
      update(firstLine, firstLine.text.slice(0, from.ch) + text[0], spansFor(0))
      doc.insert(from.line + 1, added)
    }
  } 
Copy the code

If there’s only one line

 else if (text.length == 1) {
    update(firstLine, firstLine.text.slice(0, from.ch) + text[0] + lastLine.text.slice(to.ch), spansFor(0))
    doc.remove(from.line + 1, nlines)
  } 
Copy the code

Otherwise, the default logic is executed

 else {
    update(firstLine, firstLine.text.slice(0, from.ch) + text[0], spansFor(0))
    update(lastLine, lastText + lastLine.text.slice(to.ch), lastSpans)
    let added = linesFor(1, text.length - 1)
    if (nlines > 1) doc.remove(from.line + 1, nlines - 1)
    doc.insert(from.line + 1, added)
  }
Copy the code

Here the specific logic is mostly data processing, according to different conditions to carry out what kind of data processing. Just to sort out the logic of the call

  • As I mentioned earlier, it’s a lot like the Node.js source code in that it’s easy to call values for methods and properties that are attached to the prototype chain, as well as Doc

The Doc prototype has more than 400 lines of code for mounting methods, so I’ll pick a few representative ones to illustrate:

Insert: function(at, lines) {let height = 0 for (let I = 0; i < lines.length; ++ I) height += lines[I]. Height this. InsertInner (at-this. first, lines, height)}, Function (at, n) {this.removeinner (at-this.first, n)}, // replaceRange: function(code, from, to, origin) { from = clipPos(this, from) to = to ? ClipPos (this, to) : from replaceRange(this, code, from, to, origin)}, function(from, to, lineSep) { let lines = getBetween(this, clipPos(this, from), clipPos(this, to)) if (lineSep === false) return lines if (lineSep === '') return lines.join('') return lines.join(lineSep || GetLine: function(line) {let l = this.getlineHandle (line); FirstLine: function() {return this.first}, // get lastLine: function() {return this.first}, // get lastLine: function() {return this.first + this.size - 1},Copy the code
  • The logic of the editor is almost clear, inDocFormat the value as a Doc, and then format the value as a DocinputThe style is initialized, and the editor’s input node is passed along with the formatted docDisplaymethods

The input style is initialized

let input = new CodeMirror.inputStyles[options.inputStyle](this)
Copy the code

Pass the editor’s input node along with the formatted doc to the Display method

 if (typeof doc == "string") doc = new Doc(doc, options.mode, null, options.lineSeparator, options.direction)
  else if (options.mode) doc.modeOption = options.mode
  this.doc = doc

  let input = new CodeMirror.inputStyles[options.inputStyle](this)
  let display = this.display = new Display(place, doc, input, options)
Copy the code

This display method, I think, is mostly style manipulation, so I won’t go into it here. Back to the essence of the whole article and source code

Back to the Doc

  • The hardest thing about the whole source codeDocIn this case, it is the value passed in from the outside, and mounted inDocThe method on the prototype chain, formatting the incoming data, one must ask, how the hell does this cause the editor to re-render?

Change the Selection object, of course

  • I just wrote that inDocMount a number of methods, one of themsetValuemethods
setValue: docMethodOp(function(code) { let top = Pos(this.first, 0), last = this.first + this.size - 1 makeChange(this, {from: top, to: Pos(last, getLine(this, last).text.length), text: this.splitLines(code), origin: "setValue", full: If (this.cm) scrollToCoords(this.cm, 0, 0) setSelection(this, simpleSelection(top), sel_dontScroll) }),Copy the code

Emphasis – Set new selection:

// Set a new selection.
export function setSelection(doc, sel, options) {
  setSelectionNoUndo(doc, sel, options)
  addSelectionToHistory(doc, doc.sel, doc.cm ? doc.cm.curOp.id : NaN, options)
}

Copy the code
  • As you can see here, since the editor supports fallback, a history is added each time the editor content is updated. Fortunately, we should read the right source direction
export function setSelectionNoUndo(doc, sel, options) { if (hasHandler(doc, "beforeSelectionChange") || doc.cm && hasHandler(doc.cm, "beforeSelectionChange")) sel = filterSelectionChange(doc, sel, options) let bias = options && options.bias || (cmp(sel.primary().head, doc.sel.primary().head) < 0 ? -1 : 1) setSelectionInner(doc, skipAtomicInSelection(doc, sel, bias, true)) if (! (options && options.scroll === false) && doc.cm && doc.cm.getOption("readOnly") ! = "nocursor") ensureCursorVisible(doc.cm) }Copy the code

No blocking return code was found, so you should look at setSelectionInner

function setSelectionInner(doc, sel) {
  if (sel.equals(doc.sel)) return

  doc.sel = sel

  if (doc.cm) {
    doc.cm.curOp.updateInput = 1
    doc.cm.curOp.selectionChanged = true
    signalCursorActivity(doc.cm)
  }
  signalLater(doc, "cursorActivity", doc)
}

Copy the code
  • thissetSelectionInnerWill make a judgment if both are newselTerminate the function call if the object is consistent with the old comparison, otherwise update the assignment.

If the editor instance exists at the moment, update some of its properties, such as selectionChanged, which should be a lock-like identifier that tells other callers that the Selection object has changed, and finally call the signalLater object’s methods

The final code needs to come back to use

  • How does bytedance’s editor call it
import React, { useEffect, useRef } from 'react'; import * as bytemd from 'bytemd'; export interface EditorProps extends bytemd.EditorProps { onChange? (value: string): void; } export const Editor: React.FC<EditorProps> = ({ children, onChange, ... props }) => { const ed = useRef<bytemd.Editor>(); const el = useRef<HTMLDivElement>(null); useEffect(() => { if (! el.current) return; const editor = new bytemd.Editor({ target: el.current, props, }); editor.$on('change', (e: CustomEvent<{ value: string }>) => { onChange? .(e.detail.value); }); ed.current = editor; return () => { editor.$destroy(); }; } []); useEffect(() => { // TODO: performance ed.current? .$set(props); }, [props]); return <div ref={el}></div>; };Copy the code

The key points:

editor.$on('change', (e: CustomEvent<{ value: string }>) => { onChange? .(e.detail.value); });Copy the code
  • Listen to thechangeEvent, as it happens, the last source code above looks like this:
SignalLater (doc, "cursorActivity", doc) Export function signalLater(Emitter, type /*, values... */) { let arr = getHandlers(emitter, type) if (! arr.length) return let args = Array.prototype.slice.call(arguments, 2), list if (operationGroup) { list = operationGroup.delayedCallbacks } else if (orphanDelayedCallbacks) { list = orphanDelayedCallbacks } else { list = orphanDelayedCallbacks = [] setTimeout(fireOrphanDelayed, 0) } for (let i = 0; i < arr.length; ++i) list.push(() => arr[i].apply(null, args)) }Copy the code

All listener events are triggered once, so that, for example, every time I set a new Selection, the external change event is triggered.

 list.push(() => arr[i].apply(null, args))
Copy the code
  • Each time the callback is passed, it looks like thischangeSame method, getdomnode
editor.$on('change', (e: CustomEvent<{ value: string }>) => { onChange? .(e.detail.value); });Copy the code

Comb the process of looking at the source code

  • cloneBytedance editor source code
  • Found to bepnpm + lernaPattern, usingnvmswitchnodejsVersion, install dependencies
  • Bytedance was discovered usingSvelteFrame, dockingCodeMirror
  • Local link or YALCDebugging the source code found that the editor bottom layer depended onCodeMirror
  • findCodeMirrorThe source code
  • CodeMirrorThe source code withnodejsThe design is similar. It was written 5 years ago. I think most of them are
  • foundCodeMirrorIs through thefromTextAreaMethod to return the instance to start using
  • That’s the pointDocMethod that will be passed invalueInitialize and display in the editor (by settingSelectionObject mode), and indocThere are many methods mounted on the instance that are exposed to external component calls (important)
  • Briefly analyzedDisplayMethod, for style and some node handling (non-important)

Write in the last

  • I thought an article would only take an hour to finish, but I didn’t expect to encounter a rather miscellaneous technology stack. Fortunately, I have read itnodejsSome core module source code, or I can’t write
  • Bytedance’s technology is awesome and the Nuggets have improved their editor a lot. Thumbs up
  • I am aPeterAn undignified oneWeb DeveloperIf you feel good, please give me a thumbs-up and follow the official account:The front-end peakI’ve been working on a book called100 sets of front-end architecture“, welcome to exchange ~