2

Libraries such as immutablejs and immer have given js the possibility of immutable programming, but there are still some unsolvable problems, that is, "how to ensure that an object is truly immutable".

If it weren't for the guarantee, there is really no other way. Maybe you think frozen is a good idea, but it can still add keys frozen

Another problem is that when we debug the application data, when we see that the state changes from [] -> [] , no matter whether it is in the console, breakpoints, redux devtools or .toString() we can’t tell whether the reference has changed, unless the variable value is changed. Respectively get the === runtime judgment. But whether the citation has changed or not is a big problem, and it can even determine whether the business logic is correct or not.

But at this stage, we don’t have any solution. If you can’t accept the use of Immutablejs to define objects, you can only show your confidence that your changes must be immutable. This is the reason why js immutable programming has been complained by many smart people. Forcing immutable thinking under the immutable programming language is a very awkward thing.

proposal-record-tuple solves this problem. It allows js to support the 161c9253b7154d immutable data type (highlight, bold).

Overview & Intensive Reading

JS has 7 primitive types: string, number, bigint, boolean, undefined, symbol, null. The Records & Tuples proposal adds three primitive types! These three primitive types completely serve the immutable programming environment, that is to say, it can make js open a native immutable track.

The three primitive types are Record, Tuple, Box:

  • Record: The deep immutable basic type of the class object structure, such as #{ x: 1, y: 2 } .
  • Tuple: The depth immutable basic type of the array-like structure, such as #[1, 2, 3, 4] .
  • Box: can be defined in the above two types to store objects, such as #{ prop: Box(object) } .

The core idea can be summarized in one sentence: Because these three types are basic types, value comparison (not reference comparison) is used when comparing, so #{ x: 1, y: 2} === #{ x: 1, y: 2 } . This really solves the big problem! If you still don't understand the pain of js not supporting immutable, please don't skip the next section.

js does not support immutable pain

Although many people like the reactive features of mvvm (including I have written a lot of mvvm wheels and frameworks), immutable data is always the best idea for developing large-scale applications. It can guarantee the predictability of application data very reliably. At the same time, there is no need to sacrifice performance and memory. It is not convenient to use in mutable mode, but it will never appear unexpected. This is essential for creating stable and complex applications, even more important than convenience. Of course, testability is also a very important point, and I will not expand it in detail here.

However, js does not natively support immutable, which is very troublesome and causes a lot of trouble. I will try to explain this trouble below.

If you think the comparison of non-primitive types by reference is great, then you must be able to see at a glance that the following results are correct:

assert({ a: 1 } !== { a: 1 })

But what if it is the following situation?

console.log(window.a) // { a: 1 }
console.log(window.b) // { a: 1 }
assert(window.a === window.b) // ???

result of 161c9253b71712 is not sure about . Although the two objects look the same, the scope we get cannot infer whether they come from the same reference. If they come from the same reference, the assertion is passed, otherwise even if the values seem to be the same. Will throw error.

The bigger trouble is that even if the two objects look completely different, we dare not easily draw conclusions:

console.log(window.a) // { a: 1 }
// do some change..
console.log(window.b) // { b: 1 }
assert(window.a === window.b) // ???

Because the value of b may be modified in the middle, but it does come from the same reference as a, we cannot tell what the result is.

Another problem is the confusing and confusing application state changes. Imagine that we have developed a tree menu with the following structure:

{
  "id": "1",
  "label": "root",
  "children": [{
    "id": "2",
    "label": "apple",
  }, {
    "id": "3",
    "label": "orange",
  }]
}

If we call updateTreeNode('3', { id: '3', title: 'banana' }) , in the immutable scenario, we only update the reference of the component with id "1", "3", and the reference with id "2" remains unchanged, then the tree node "2" will not be re-rendered. This is the immutable thinking logic of pure blood.

But when we save this new state, we need to perform "state playback", and we will find that the application state has actually been changed once, and the entire description json becomes:

{
  "id": "1",
  "label": "root",
  "children": [{
    "id": "2",
    "label": "apple",
  }, {
    "id": "3",
    "label": "banana",
  }]
}

But if we copy the above text and set the application state directly to this result, we will find that the effect is different from the "apply playback button". At this time, id "2" is also re-rendered because its reference has changed.

The problem is that we cannot observe whether the reference has changed with the naked eye. Even if the two structures are exactly the same, there is no guarantee that the reference is the same, which makes it impossible to infer whether the behavior of the application is consistent. If there is no human code quality control, it is almost inevitable to update the unanticipated references.

This is the background of the Records & Tuples proposal to solve the problem. We can learn more by looking at its definition with this understanding.

Records & Tuples are consistent in usage with objects and arrays

The Records & Tuples proposal states that the immutable data structure needs to be # symbol when it is defined, and it is no different from ordinary objects and arrays when used.

Record usage is almost the same as ordinary object:

const proposal = #{
  id: 1234,
  title: "Record & Tuple proposal",
  contents: `...`,
  // tuples are primitive types so you can put them in records:
  keywords: #["ecma", "tc39", "proposal", "record", "tuple"],
};

// Accessing keys like you would with objects!
console.log(proposal.title); // Record & Tuple proposal
console.log(proposal.keywords[1]); // tc39

// Spread like objects!
const proposal2 = #{
  ...proposal,
  title: "Stage 2: Record & Tuple",
};
console.log(proposal2.title); // Stage 2: Record & Tuple
console.log(proposal2.keywords[1]); // tc39

// Object functions work on Records:
console.log(Object.keys(proposal)); // ["contents", "id", "keywords", "title"]

The following example shows that there is no difference between Records and object when processed in a function. This is mentioned in the FAQ as a very important feature that allows immutable to be fully integrated into the current js ecology:

const ship1 = #{ x: 1, y: 2 };
// ship2 is an ordinary object:
const ship2 = { x: -1, y: 3 };

function move(start, deltaX, deltaY) {
  // we always return a record after moving
  return #{
    x: start.x + deltaX,
    y: start.y + deltaY,
  };
}

const ship1Moved = move(ship1, 1, 0);
// passing an ordinary object to move() still works:
const ship2Moved = move(ship2, 3, -1);

console.log(ship1Moved === ship2Moved); // true
// ship1 and ship2 have the same coordinates after moving

Tuple usage is almost the same as ordinary array:

const measures = #[42, 12, 67, "measure error: foo happened"];

// Accessing indices like you would with arrays!
console.log(measures[0]); // 42
console.log(measures[3]); // measure error: foo happened

// Slice and spread like arrays!
const correctedMeasures = #[
  ...measures.slice(0, measures.length - 1),
  -1
];
console.log(correctedMeasures[0]); // 42
console.log(correctedMeasures[3]); // -1

// or use the .with() shorthand for the same result:
const correctedMeasures2 = measures.with(3, -1);
console.log(correctedMeasures2[0]); // 42
console.log(correctedMeasures2[3]); // -1

// Tuples support methods similar to Arrays
console.log(correctedMeasures2.map(x => x + 1)); // #[43, 13, 68, 0]

When processing in the function, there is no difference that needs special attention when getting an array or Tuple:

const ship1 = #[1, 2];
// ship2 is an array:
const ship2 = [-1, 3];

function move(start, deltaX, deltaY) {
  // we always return a tuple after moving
  return #[
    start[0] + deltaX,
    start[1] + deltaY,
  ];
}

const ship1Moved = move(ship1, 1, 0);
// passing an array to move() still works:
const ship2Moved = move(ship2, 3, -1);

console.log(ship1Moved === ship2Moved); // true
// ship1 and ship2 have the same coordinates after moving

Since ordinary objects cannot be defined in Record (such as immutable objects defined as # mark), if ordinary objects must be used, they can only be wrapped in Box, and when obtaining the value, you need to call .unbox() , and even if the object value is modified , At the Record or Tuple level, it will not be considered changed:

const myObject = { x: 2 };

const record = #{
  name: "rec",
  data: Box(myObject)
};

console.log(record.data.unbox().x); // 2

// The box contents are classic mutable objects:
record.data.unbox().x = 3;
console.log(myObject.x); // 3

console.log(record === #{ name: "rec", data: Box(myObject) }); // true

In addition, you cannot use any ordinary objects or new object instances in Records & Tuples, unless you have used them to convert to ordinary objects:

const instance = new MyClass();
const constContainer = #{
    instance: instance
};
// TypeError: Record literals may only contain primitives, Records and Tuples

const tuple = #[1, 2, 3];

tuple.map(x => new MyClass(x));
// TypeError: Callback to Tuple.prototype.map may only return primitives, Records or Tuples

// The following should work:
Array.from(tuple).map(x => new MyClass(x))

grammar

Only Record, Tuple, Box can be used in Records & Tuples:

#{}
#{ a: 1, b: 2 }
#{ a: 1, b: #[2, 3, #{ c: 4 }] }
#[]
#[1, 2]
#[1, 2, #{ a: 3 }]

Empty array items are not supported:

const x = #[,]; // SyntaxError, holes are disallowed by syntax

In order to prevent the reference from being traced back to the upper level and destroy the immutable nature, the definition of prototype chain is not supported:

const x = #{ __proto__: foo }; // SyntaxError, __proto__ identifier prevented by syntax

const y = #{ ["__proto__"]: foo }; // valid, creates a record with a "__proto__" property.

Nor can you define methods in it:

#{ method() { } }  // SyntaxError

At the same time, some features that destroy the immutable stable structure are also illegal, for example, the key cannot be a Symbol:

const record = #{ [Symbol()]: #{} };
// TypeError: Record may only have string as keys

You cannot directly use an object as a value, unless it is wrapped in a Box:

const obj = {};
const record = #{ prop: obj }; // TypeError: Record may only contain primitive values
const record2 = #{ prop: Box(obj) }; // ok

Sentence

Judgment is the core point. The Records & Tuples proposal requires that == and === native support for immutable judgment, which is an important manifestation of js's native support for immutable, so the judgment logic is quite different from ordinary object judgments:

First of all, if the values are equal, they are really equal, because the basic types only compare values:

assert(#{ a: 1 } === #{ a: 1 });
assert(#[1, 2] === #[1, 2]);

This is completely different from the object judgment, and after the Record is converted to an object, the judgment follows the rules of the object:

assert({ a: 1 } !== { a: 1 });
assert(Object(#{ a: 1 }) !== Object(#{ a: 1 }));
assert(Object(#[1, 2]) !== Object(#[1, 2]));

In addition, the judgment of Records has nothing to do with the order of keys, because there is an implicit key sorting rule:

assert(#{ a: 1, b: 2 } === #{ b: 2, a: 1 });

Object.keys(#{ a: 1, b: 2 })  // ["a", "b"]
Object.keys(#{ b: 2, a: 1 })  // ["a", "b"]

Whether Box is equal depends on whether the internal object references are equal:

const obj = {};
assert(Box(obj) === Box(obj));
assert(Box({}) !== Box({}));

For +0 -0 between, NaN and NaN contrast, can be safely determined to be equal, but Object.is because ordinary determination logic of the object, it will be considered #{ a: -0 } not equal #{ a: +0 } , because that -0 not equal +0 , require special attention here. In addition, Records & Tulpes can also be used as the keys of Map and Set, and search according to the same value:

assert(#{ a:  1 } === #{ a: 1 });
assert(#[1] === #[1]);

assert(#{ a: -0 } === #{ a: +0 });
assert(#[-0] === #[+0]);
assert(#{ a: NaN } === #{ a: NaN });
assert(#[NaN] === #[NaN]);

assert(#{ a: -0 } == #{ a: +0 });
assert(#[-0] == #[+0]);
assert(#{ a: NaN } == #{ a: NaN });
assert(#[NaN] == #[NaN]);
assert(#[1] != #["1"]);

assert(!Object.is(#{ a: -0 }, #{ a: +0 }));
assert(!Object.is(#[-0], #[+0]));
assert(Object.is(#{ a: NaN }, #{ a: NaN }));
assert(Object.is(#[NaN], #[NaN]));

// Map keys are compared with the SameValueZero algorithm
assert(new Map().set(#{ a: 1 }, true).get(#{ a: 1 }));
assert(new Map().set(#[1], true).get(#[1]));
assert(new Map().set(#[-0], true).get(#[0]));

How the object model handles Records & Tuples

The object model refers to the Object model. In most cases, all methods that can be applied to ordinary objects can be seamlessly applied to Record. For example, Object.key or in can be used in the same way as ordinary objects:

const keysArr = Object.keys(#{ a: 1, b: 2 }); // returns the array ["a", "b"]
assert(keysArr[0] === "a");
assert(keysArr[1] === "b");
assert(keysArr !== #["a", "b"]);
assert("a" in #{ a: 1, b: 2 });

It is worth mentioning that if the wrapper Object in Record or Tuple, the proposal also prepares a complete implementation plan, that is, Object(record) or Object(tuple) will freeze all attributes and point the prototype chain up to Tuple.prototype , which can only be used for array cross-border access. Return undefined instead of going back along the prototype chain.

Standard library support for Records & Tuples

After performing native array or object operations on Record and Tuple, the return value is also immutable:

assert(Object.keys(#{ a: 1, b: 2 }) === #["a", "b"]);
assert(#[1, 2, 3].map(x => x * 2), #[2, 4, 6]);

You can also use the Record.fromEntries and Tuple.from methods to convert ordinary objects or arrays into Record, Tuple:

const record = Record({ a: 1, b: 2, c: 3 });
const record2 = Record.fromEntries([#["a", 1], #["b", 2], #["c", 3]]); // note that an iterable will also work
const tuple = Tuple(...[1, 2, 3]);
const tuple2 = Tuple.from([1, 2, 3]); // note that an iterable will also work

assert(record === #{ a: 1, b: 2, c: 3 });
assert(tuple === #[1, 2, 3]);
Record.from({ a: {} }); // TypeError: Can't convert Object with a non-const value to Record
Tuple.from([{}, {} , {}]); // TypeError: Can't convert Iterable with a non-const value to Tuple

This method does not support nesting, because the standard API only considers one level, and recursion is generally implemented by business or library functions, just like Object.assign .

Record and Tuple are also iterable:

const tuple = #[1, 2];

// output is:
// 1
// 2
for (const o of tuple) { console.log(o); }

const record = #{ a: 1, b: 2 };

// TypeError: record is not iterable
for (const o of record) { console.log(o); }

// Object.entries can be used to iterate over Records, just like for Objects
// output is:
// a
// b
for (const [key, value] of Object.entries(record)) { console.log(key) }

JSON.stringify will convert Record & Tuple into ordinary objects:

JSON.stringify(#{ a: #[1, 2, 3] }); // '{"a":[1,2,3]}'
JSON.stringify(#[true, #{ a: #[1, 2, 3] }]); // '[true,{"a":[1,2,3]}]'

But at the same time, it is recommended to implement JSON.parseImmutable to directly convert a JSON into a Record & Tuple type, and its API is no different JSON.parse

The Tuple.prototype method is very similar to Array, but there are some differences. The main difference is that it does not modify the reference value, but creates a new reference. For details, see appendix .

Since three new primitive types have been added, typeof will also add three new return results:

assert(typeof #{ a: 1 } === "record");
assert(typeof #[1, 2]   === "tuple");
assert(typeof Box({}) === "box");

Record, Tuple, Box all support as the key of Map and Set, and judge according to their own rules, namely

const record1 = #{ a: 1, b: 2 };
const record2 = #{ a: 1, b: 2 };

const map = new Map();
map.set(record1, true);
assert(map.get(record2));
const record1 = #{ a: 1, b: 2 };
const record2 = #{ a: 1, b: 2 };

const set = new Set();
set.add(record1);
set.add(record2);
assert(set.size === 1);

But WeakMap and WeakSet are not supported:

const record = #{ a: 1, b: 2 };
const weakMap = new WeakMap();

// TypeError: Can't use a Record as the key in a WeakMap
weakMap.set(record, true);
const record = #{ a: 1, b: 2 };
const weakSet = new WeakSet();

// TypeError: Can't add a Record to a WeakSet
weakSet.add(record);

The reason is that immutable data does not have a predictable garbage collection timing, so if it is used in the Weak series, it will not be released in time, so the API does not match.

Finally, the proposal also comes with a theoretical foundation and FAQ chapter, which will be briefly introduced below.

Theoretical basis

Why create new primitive types instead of processing them in the upper layer like other libraries?

In a word, for js to natively support immutable, it must be used as a primitive type. If it is not used as a primitive type, it is impossible for the == and === operators to natively support specific judgments of this type, and it will also cause the immutable syntax and other js codes to appear to be under two logical systems, hindering the unification of the ecology.

Will developers be familiar with this syntax?

Since the consistency with common object and array processing and API is guaranteed to the greatest extent, it should be easier for developers to get started.

Why not use the .get .set method like Immutablejs?

This will lead to ecological fragmentation, and the code needs to pay attention to whether the object is immutable. One of the most vivid examples is that when Immutablejs works with ordinary js operation library, you need to write code similar to the following:

state.jobResult = Immutable.fromJS(
    ExternalLib.processJob(
        state.jobDescription.toJS()
    )
);

This has a very strong sense of fragmentation.

Why not use the global Record, Tuple method instead of the # statement?

Two comparisons are given below:

// with the proposed syntax
const record = #{
  a: #{
    foo: "string",
  },
  b: #{
    bar: 123,
  },
  c: #{
    baz: #{
      hello: #[
        1,
        2,
        3,
      ],
    },
  },
};

// with only the Record/Tuple globals
const record = Record({
  a: Record({
    foo: "string",
  }),
  b: Record({
    bar: 123,
  }),
  c: Record({
    baz: Record({
      hello: Tuple(
        1,
        2,
        3,
      ),
    }),
  }),
});

Obviously the latter is not as concise as the former, and it also breaks the developer's perception of objects and arrays like.

Why use #[]/#{} syntax?

Using the existing keywords can lead to ambiguity or compatibility problem, and in fact, there {| |} [| |] of proposal , but # chance of winning is relatively large.

Why is the depth immutable?

This proposal sprayed Object.freeze :

const object = {
   a: {
       foo: "bar",
   },
};
Object.freeze(object);
func(object);

Since only one layer is guaranteed, object.a still variable. Since js needs to natively support immutable, the hope must be that the depth is immutable instead of only one layer.

In addition, since this grammar will support immutable verification at the language level, deep immutable verification is very important.

FAQ

How to create a new immutable object based on an existing immutable object?

Most of the syntax can be used, such as deconstruction:

// Add a Record field
let rec = #{ a: 1, x: 5 }
#{ ...rec, b: 2 }  // #{ a: 1, b: 2, x: 5 }

// Change a Record field
#{ ...rec, x: 6 }  // #{ a: 1, x: 6 }

// Append to a Tuple
let tup = #[1, 2, 3];
#[...tup, 4]  // #[1, 2, 3, 4]

// Prepend to a Tuple
#[0, ...tup]  // #[0, 1, 2, 3]

// Prepend and append to a Tuple
#[0, ...tup, 4]  // #[0, 1, 2, 3, 4]

For array-like Tuples, you can use the with syntax to replace and create a new object:

// Change a Tuple index
let tup = #[1, 2, 3];
tup.with(1, 500)  // #[1, 500, 3]

during the in-depth modification. There is currently a 161c9253b724ce proposal discussing this matter. Here is an interesting grammar:

const state1 = #{
    counters: #[
        #{ name: "Counter 1", value: 1 },
        #{ name: "Counter 2", value: 0 },
        #{ name: "Counter 3", value: 123 },
    ],
    metadata: #{
        lastUpdate: 1584382969000,
    },
};

const state2 = #{
    ...state1,
    counters[0].value: 2,
    counters[1].value: 1,
    metadata.lastUpdate: 1584383011300,
};

assert(state2.counters[0].value === 2);
assert(state2.counters[1].value === 1);
assert(state2.metadata.lastUpdate === 1584383011300);

// As expected, the unmodified values from "spreading" state1 remain in state2.
assert(state2.counters[2].value === 123);

counters[0].value: 2 still looks quite novel.

The relationship with Readonly Collections

Complementary.

Can Record instances be created based on Class?

Not currently considered.

TS also has Record and Tuple keywords. What is the relationship between them?

Those who are familiar with TS know that only the name is the same.

What are the performance expectations?

This problem is very critical. If the performance of this proposal is not good, it cannot be used in actual production.

There are no performance requirements at the current stage, but best practices for vendor optimization will be given before Stage4.

Summarize

If this proposal is passed along with the nested update proposal, the use of immutable in js will be guaranteed at the language level, and libraries including Immutablejs and immerjs can really be laid off.

The discussion address is: "Records & Tuples Proposal" · Issue #384 · dt-fe/weekly

If you want to participate in the discussion, please click here , there are new topics every week, weekend or Monday release. Front-end intensive reading-to help you filter reliable content.

Follow front-end intensive reading WeChat public

<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">

Copyright notice: Freely reproduced-non-commercial-non-derivative-keep the signature ( Creative Commons 3.0 License )

黄子毅
7k 声望9.5k 粉丝